Compare commits
42 Commits
feat/aep-t
...
c6295ea228
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c6295ea228 | ||
|
|
cd2d225e91 | ||
|
|
11732a6a4b | ||
|
|
538c9a1214 | ||
|
|
8d673482b6 | ||
|
|
586742d90e | ||
|
|
668ae5caff | ||
|
|
f5732bf336 | ||
|
|
5967a5af57 | ||
|
|
419071b4c5 | ||
|
|
f7b01c33e6 | ||
|
|
a72928343f | ||
|
|
f9a0875727 | ||
|
|
ec9178be08 | ||
|
|
6525afd5f5 | ||
|
|
4d7e8bede9 | ||
|
|
70b9b1aa3c | ||
|
|
cc93571d94 | ||
|
|
2b34d05585 | ||
|
|
19ff17e236 | ||
|
|
eb1bcf6080 | ||
|
|
9eb66ac10c | ||
|
|
0378f2bd72 | ||
|
|
f0696a8fb3 | ||
|
|
03127b1648 | ||
|
|
0099627da4 | ||
|
|
f9960bf8ea | ||
|
|
05bbcc2a02 | ||
|
|
f518318d60 | ||
|
|
0598536244 | ||
|
|
b951fe0b8d | ||
|
|
c8311ce1fb | ||
|
|
142e5cf787 | ||
|
|
606b9f0a47 | ||
|
|
6f7d2450de | ||
|
|
e7c7d302ea | ||
|
|
4ed0a87106 | ||
|
|
825b0ddeb2 | ||
|
|
d345d7f6f9 | ||
|
|
3347b3f859 | ||
|
|
9c4f4b8e87 | ||
|
|
5103942698 |
@@ -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
|
## 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
|
**Commit :** `a02a555` — feat(mobile): accordéon outremer, hamburger nav, logo AEP, fiches cliquables, chatbot fullscreen
|
||||||
|
|||||||
178
app.vue
178
app.vue
@@ -7,21 +7,16 @@
|
|||||||
style="background: var(--nav-surface); border-bottom: 1px solid var(--nav-bg-alt);"
|
style="background: var(--nav-surface); border-bottom: 1px solid var(--nav-bg-alt);"
|
||||||
>
|
>
|
||||||
<!-- Logo -->
|
<!-- Logo -->
|
||||||
<a href="/" class="flex items-center gap-2 hover:opacity-90 transition-opacity shrink-0 group relative" title="Architecture d'Écologie Politique">
|
<a href="/" class="logo-link flex items-center gap-2 hover:opacity-90 transition-opacity shrink-0" title="Architecture d'Écologie Politique">
|
||||||
<div
|
<div
|
||||||
class="h-7 px-2 rounded-lg flex items-center justify-center shrink-0"
|
class="h-8 px-2 rounded-lg flex items-center justify-center shrink-0"
|
||||||
style="background: var(--nav-primary-solid);"
|
style="background: var(--nav-primary-solid);"
|
||||||
>
|
>
|
||||||
<span class="font-bold text-xs tracking-tight" style="color: var(--nav-text-on-primary);">AEP</span>
|
<span class="font-bold text-xs tracking-tight" style="color: var(--nav-text-on-primary);">AEP</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col">
|
<div class="logo-text flex flex-col leading-tight">
|
||||||
<span class="font-bold text-base tracking-tight leading-tight" style="color: var(--nav-text);">AEP</span>
|
<span class="logo-line-1 font-bold tracking-tight" style="color: var(--nav-text);">Architecture</span>
|
||||||
<span class="text-xs leading-tight hidden lg:inline" style="color: var(--nav-text-muted);">Architecture d'Écologie Politique</span>
|
<span class="logo-line-2 font-bold tracking-tight" style="color: var(--nav-text);">d'Écologie Politique</span>
|
||||||
</div>
|
|
||||||
<!-- Tooltip sm (quand le sous-titre lg est caché) -->
|
|
||||||
<div class="absolute left-0 top-full mt-2 px-2 py-1 rounded text-xs whitespace-nowrap pointer-events-none opacity-0 group-hover:opacity-100 transition-opacity lg:hidden z-50"
|
|
||||||
style="background: var(--nav-primary-solid); color: var(--nav-text-on-primary);">
|
|
||||||
Architecture d'Écologie Politique
|
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
@@ -39,8 +34,21 @@
|
|||||||
class="nav-tab"
|
class="nav-tab"
|
||||||
:class="{ 'nav-tab--active': route.path === '/agences' }"
|
:class="{ 'nav-tab--active': route.path === '/agences' }"
|
||||||
>
|
>
|
||||||
Agences Inspirantes
|
Réseaux AEP
|
||||||
<span class="nav-tab-badge">en construction</span>
|
</NuxtLink>
|
||||||
|
<NuxtLink
|
||||||
|
to="/trouver-du-taf"
|
||||||
|
class="nav-tab"
|
||||||
|
:class="{ 'nav-tab--active': route.path === '/trouver-du-taf' }"
|
||||||
|
>
|
||||||
|
Jobs
|
||||||
|
</NuxtLink>
|
||||||
|
<NuxtLink
|
||||||
|
to="/codev"
|
||||||
|
class="nav-tab"
|
||||||
|
:class="{ 'nav-tab--active': route.path.startsWith('/codev') }"
|
||||||
|
>
|
||||||
|
Codev
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
to="/rag"
|
to="/rag"
|
||||||
@@ -100,14 +108,52 @@
|
|||||||
>
|
>
|
||||||
Signaler
|
Signaler
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<!-- Proposer une ressource -->
|
<!-- Proposer — popover 3 choix -->
|
||||||
<NuxtLink
|
<div class="hidden sm:block relative" ref="proposerAnchor" data-proposer-popover>
|
||||||
to="/contribuer"
|
<button
|
||||||
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"
|
@click="proposerOpen = !proposerOpen"
|
||||||
style="background: var(--nav-accent); color: var(--nav-text);"
|
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);"
|
||||||
+ Proposer
|
aria-label="Proposer une contribution"
|
||||||
</NuxtLink>
|
>
|
||||||
|
+ 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 -->
|
<!-- Toggle dark mode -->
|
||||||
<button
|
<button
|
||||||
@@ -129,18 +175,40 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Mobile : contribuer icône -->
|
<!-- Mobile : contribuer icône → popover -->
|
||||||
<NuxtLink
|
<div class="sm:hidden relative" data-proposer-popover>
|
||||||
to="/contribuer"
|
<button
|
||||||
class="sm:hidden p-2 rounded-lg"
|
@click="proposerOpen = !proposerOpen"
|
||||||
style="background: var(--nav-accent); color: var(--nav-text);"
|
class="p-2 rounded-lg"
|
||||||
title="Contribuer une fiche"
|
style="background: var(--nav-accent); color: var(--nav-text);"
|
||||||
aria-label="Contribuer"
|
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 width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||||
</svg>
|
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
|
||||||
</NuxtLink>
|
</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 -->
|
<!-- Hamburger mobile (lg:hidden) — toujours en dernier à droite -->
|
||||||
<div class="lg:hidden relative">
|
<div class="lg:hidden relative">
|
||||||
@@ -165,10 +233,14 @@
|
|||||||
@click="hamburgerOpen = false"
|
@click="hamburgerOpen = false"
|
||||||
>
|
>
|
||||||
<NuxtLink to="/" class="block px-4 py-2.5 text-sm font-medium transition-opacity hover:opacity-70" :style="route.path === '/' ? 'color: var(--nav-primary-solid); font-weight: 700;' : 'color: var(--nav-text);'">Écosystème Entraide Architecture</NuxtLink>
|
<NuxtLink to="/" 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="color: var(--nav-text);">Agences Inspirantes</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="/rag" class="block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" style="color: var(--nav-text);">RAG</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>
|
<div style="height: 1px; background: var(--nav-bg-alt); margin: 4px 0;"></div>
|
||||||
|
<NuxtLink to="/manifeste" class="block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" :style="route.path === '/manifeste' ? 'color: var(--nav-primary-solid); font-weight: 700;' : 'color: var(--nav-text-muted);'">Manifeste</NuxtLink>
|
||||||
<NuxtLink to="/a-propos" class="block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" style="color: var(--nav-text-muted);">À propos</NuxtLink>
|
<NuxtLink to="/a-propos" class="block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" style="color: var(--nav-text-muted);">À propos</NuxtLink>
|
||||||
|
<a href="https://liberapay.com/trans-former.fr/donate" target="_blank" rel="noopener noreferrer" class="block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" style="color: var(--nav-text-muted);">Soutenir →</a>
|
||||||
<NuxtLink to="/signaler" class="block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" style="color: var(--nav-text-muted);">Signaler</NuxtLink>
|
<NuxtLink to="/signaler" class="block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" style="color: var(--nav-text-muted);">Signaler</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -193,6 +265,31 @@ const route = useRoute()
|
|||||||
const hamburgerOpen = ref(false)
|
const hamburgerOpen = ref(false)
|
||||||
watch(() => route.path, () => { hamburgerOpen.value = 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 ─────────────────────────────────────────────────────────────
|
// ── Dark mode ─────────────────────────────────────────────────────────────
|
||||||
const isDark = ref(false)
|
const isDark = ref(false)
|
||||||
|
|
||||||
@@ -248,6 +345,21 @@ function goRandom() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
/* ── Logo header (texte 2 lignes) ─────────────────────────────────────── */
|
||||||
|
.logo-text {
|
||||||
|
line-height: 1.05;
|
||||||
|
}
|
||||||
|
.logo-line-1, .logo-line-2 {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
@media (min-width: 640px) {
|
||||||
|
.logo-line-1, .logo-line-2 { font-size: 0.78rem; }
|
||||||
|
}
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.logo-line-1, .logo-line-2 { font-size: 0.85rem; }
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Onglets header desktop ───────────────────────────────────────────── */
|
/* ── Onglets header desktop ───────────────────────────────────────────── */
|
||||||
.nav-tab {
|
.nav-tab {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|||||||
@@ -108,3 +108,16 @@
|
|||||||
.dark .leaflet-popup-tip {
|
.dark .leaflet-popup-tip {
|
||||||
background: var(--nav-surface);
|
background: var(--nav-surface);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Rendu Markdown chatbot (useMarkdown composable) ────────────────────── */
|
||||||
|
.md-content { font-size: inherit; line-height: 1.6; }
|
||||||
|
.md-content p { margin: 0 0 0.5em; }
|
||||||
|
.md-content p:last-child { margin-bottom: 0; }
|
||||||
|
.md-content strong, .md-h1, .md-h2, .md-h3 { font-weight: 700; }
|
||||||
|
.md-h2 { font-size: 0.9375em; display: block; margin-bottom: 0.25em; }
|
||||||
|
.md-h3 { font-size: 0.875em; display: block; }
|
||||||
|
.md-content em { font-style: italic; }
|
||||||
|
.md-list { margin: 0.375em 0 0.375em 1em; padding: 0; list-style: disc; }
|
||||||
|
.md-list li { margin-bottom: 0.2em; }
|
||||||
|
.md-link { text-decoration: underline; opacity: 0.85; }
|
||||||
|
.md-link:hover { opacity: 1; }
|
||||||
|
|||||||
23
assets/css/v2-bifurcation.css
Normal file
23
assets/css/v2-bifurcation.css
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
/* Palette familles V2 - variables locales, ne pas toucher --nav-* */
|
||||||
|
:root {
|
||||||
|
--bifurc-color-f1: #a85d3e; /* Réemploi & filières - terracotta */
|
||||||
|
--bifurc-color-f2: #c4a472; /* Frugalité & low-tech - terre crue */
|
||||||
|
--bifurc-color-f3: #d4a017; /* Architecture sociale - ocre */
|
||||||
|
--bifurc-color-f4: #5a7a4a; /* Collectifs & AMO - vert mousse */
|
||||||
|
--bifurc-color-f5: #3d6a8c; /* Urbanisme transition - bleu profond */
|
||||||
|
|
||||||
|
--bifurc-badge-f6: #6b3fa0; /* Recherche politique - violet */
|
||||||
|
--bifurc-badge-cr: #2d8a6b; /* Centre ressources - vert foncé */
|
||||||
|
--bifurc-badge-mm: #c44a2f; /* Mouvement manifeste - rouge brique */
|
||||||
|
--bifurc-badge-cp: #1a3a6b; /* Contre-pouvoir - bleu nuit */
|
||||||
|
|
||||||
|
--bifurc-banner-bg: #faf8f5;
|
||||||
|
--bifurc-banner-border: #e0d8cc;
|
||||||
|
--bifurc-banner-text: #2c2416;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bifurc-pin-f1 { background: var(--bifurc-color-f1); }
|
||||||
|
.bifurc-pin-f2 { background: var(--bifurc-color-f2); }
|
||||||
|
.bifurc-pin-f3 { background: var(--bifurc-color-f3); }
|
||||||
|
.bifurc-pin-f4 { background: var(--bifurc-color-f4); }
|
||||||
|
.bifurc-pin-f5 { background: var(--bifurc-color-f5); }
|
||||||
@@ -139,72 +139,7 @@
|
|||||||
|
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
<!-- ═══════════════════════════════════════ FAB MOBILE (< 1024px) ════════ -->
|
<!-- Mobile (< 1024px) : pas de FAB — Soutenir est dans le menu hamburger -->
|
||||||
<div v-else>
|
|
||||||
<!-- FAB soutenir (à gauche du chatbot) -->
|
|
||||||
<button
|
|
||||||
class="fab-soutenir"
|
|
||||||
type="button"
|
|
||||||
@click="fabSheetOpen = true"
|
|
||||||
aria-label="Soutenir le projet AEP"
|
|
||||||
>
|
|
||||||
<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="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- Bottom sheet FAB -->
|
|
||||||
<Teleport to="body">
|
|
||||||
<Transition name="backdrop">
|
|
||||||
<div
|
|
||||||
v-if="fabSheetOpen"
|
|
||||||
class="fixed inset-0 z-[1020]"
|
|
||||||
style="background: rgba(26,34,56,0.5);"
|
|
||||||
@click="fabSheetOpen = false"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
</Transition>
|
|
||||||
<Transition name="sheet">
|
|
||||||
<div
|
|
||||||
v-if="fabSheetOpen"
|
|
||||||
class="fab-sheet"
|
|
||||||
role="dialog"
|
|
||||||
aria-modal="true"
|
|
||||||
aria-label="Soutenir AEP"
|
|
||||||
>
|
|
||||||
<!-- Poignée -->
|
|
||||||
<div class="flex justify-center pt-3 pb-1">
|
|
||||||
<div class="rounded-full" style="width: 36px; height: 4px; background: var(--nav-bg-alt);" />
|
|
||||||
</div>
|
|
||||||
<div class="px-5 pb-6">
|
|
||||||
<h2 class="text-base font-bold mb-2" style="color: var(--nav-text);">Soutenir AEP</h2>
|
|
||||||
<template v-if="stats">
|
|
||||||
<p class="text-sm mb-1" style="color: var(--nav-text-muted);">
|
|
||||||
Coût IA ce mois : <strong>{{ stats.cout_mois_eur.toFixed(2) }} €</strong>
|
|
||||||
· Tokens : {{ stats.tokens_mois.toLocaleString('fr-FR') }}
|
|
||||||
</p>
|
|
||||||
<p class="text-sm mb-3" style="color: var(--nav-text-muted);">
|
|
||||||
{{ stats.fiches_semaine }} fiche{{ stats.fiches_semaine !== 1 ? 's' : '' }} ajoutée{{ stats.fiches_semaine !== 1 ? 's' : '' }} cette semaine
|
|
||||||
</p>
|
|
||||||
</template>
|
|
||||||
<p class="text-sm mb-4" style="color: var(--nav-text-muted); line-height: 1.5;">
|
|
||||||
1 € = 30 fiches mises en ligne. AEP est libre, sans pub, financé par les dons.
|
|
||||||
</p>
|
|
||||||
<a
|
|
||||||
href="https://liberapay.com/trans-former.fr/donate"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
class="block w-full text-center py-3 rounded-xl font-semibold text-sm"
|
|
||||||
style="background: var(--nav-primary); color: var(--nav-text-on-primary); text-decoration: none;"
|
|
||||||
@click="fabSheetOpen = false"
|
|
||||||
>
|
|
||||||
Soutenir sur Liberapay →
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Transition>
|
|
||||||
</Teleport>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
@@ -221,7 +156,6 @@ interface Stats {
|
|||||||
const stats = ref<Stats | null>(null)
|
const stats = ref<Stats | null>(null)
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const modalOpen = ref(false)
|
const modalOpen = ref(false)
|
||||||
const fabSheetOpen = ref(false)
|
|
||||||
const tooltipVisible = ref(false)
|
const tooltipVisible = ref(false)
|
||||||
|
|
||||||
// Desktop — replié par défaut, déploie au hover, replie immédiatement à la sortie
|
// Desktop — replié par défaut, déploie au hover, replie immédiatement à la sortie
|
||||||
@@ -460,39 +394,6 @@ const jaugePct = computed(() => {
|
|||||||
border-top-color: var(--nav-primary-solid, #1a2238);
|
border-top-color: var(--nav-primary-solid, #1a2238);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── FAB mobile soutenir ─────────────────────────────────────────────────── */
|
|
||||||
.fab-soutenir {
|
|
||||||
position: fixed;
|
|
||||||
bottom: 68px; /* au-dessus du FAB chatbot à 24px du bas + 48px de hauteur */
|
|
||||||
left: 16px;
|
|
||||||
z-index: 1000;
|
|
||||||
width: 44px;
|
|
||||||
height: 44px;
|
|
||||||
border-radius: 50%;
|
|
||||||
border: none;
|
|
||||||
background: var(--nav-accent);
|
|
||||||
color: var(--nav-text);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
box-shadow: 0 4px 16px rgba(26,34,56,0.25);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: opacity 0.15s, transform 0.1s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fab-soutenir:hover { opacity: 0.88; transform: translateY(-1px); }
|
|
||||||
|
|
||||||
/* ── Bottom sheet FAB ────────────────────────────────────────────────────── */
|
|
||||||
.fab-sheet {
|
|
||||||
position: fixed;
|
|
||||||
inset-x: 0;
|
|
||||||
bottom: 0;
|
|
||||||
z-index: 1021;
|
|
||||||
background: var(--nav-surface);
|
|
||||||
border-radius: 16px 16px 0 0;
|
|
||||||
box-shadow: 0 -4px 32px rgba(26,34,56,0.18);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Modal ───────────────────────────────────────────────────────────────── */
|
/* ── Modal ───────────────────────────────────────────────────────────────── */
|
||||||
.modal-backdrop {
|
.modal-backdrop {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
|||||||
145
components/CartePensees.vue
Normal file
145
components/CartePensees.vue
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
<template>
|
||||||
|
<div style="width: 100%; height: 100%; position: relative; background: var(--nav-bg);">
|
||||||
|
<svg ref="svgRef" style="width: 100%; height: 100%;"></svg>
|
||||||
|
<div ref="tooltipRef" style="
|
||||||
|
position: absolute; pointer-events: none;
|
||||||
|
background: var(--nav-surface); border: 1px solid var(--nav-bg-alt);
|
||||||
|
border-radius: 6px; padding: 8px 12px; font-size: 0.78rem;
|
||||||
|
color: var(--nav-text); max-width: 240px; box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
||||||
|
opacity: 0; transition: opacity 0.15s; z-index: 100;
|
||||||
|
"></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
interface EcoleData { id: string; label: string; description: string; color: string; x_hint: number; y_hint: number }
|
||||||
|
interface LivreRag { slug: string; titre: string; annee: number; couches: string[] }
|
||||||
|
interface AuteurData { id: string; nom: string; dates: string; ecoles: string[]; ecole_principale: string; livres_rag: LivreRag[]; theses_cles: string[]; bio_courte: string }
|
||||||
|
interface PenseesData { ecoles: EcoleData[]; auteurs: AuteurData[] }
|
||||||
|
|
||||||
|
const props = defineProps<{ data: PenseesData | null; active?: boolean }>()
|
||||||
|
const emit = defineEmits<{ 'select-auteur': [id: string] }>()
|
||||||
|
|
||||||
|
const svgRef = ref<SVGElement | null>(null)
|
||||||
|
const tooltipRef = ref<HTMLElement | null>(null)
|
||||||
|
let simulation: any = null
|
||||||
|
let d3NodeSel: any = null
|
||||||
|
let d3LinkSel: any = null
|
||||||
|
|
||||||
|
async function initGraph() {
|
||||||
|
if (!svgRef.value || !props.data) return
|
||||||
|
const d3 = await import('d3')
|
||||||
|
const svgEl = svgRef.value
|
||||||
|
const W = svgEl.clientWidth || 900
|
||||||
|
const H = svgEl.clientHeight || 600
|
||||||
|
|
||||||
|
d3.select(svgEl).selectAll('*').remove()
|
||||||
|
const svg = d3.select(svgEl).attr('viewBox', `0 0 ${W} ${H}`)
|
||||||
|
const g = svg.append('g')
|
||||||
|
|
||||||
|
svg.call(d3.zoom<SVGElement, unknown>().scaleExtent([0.3, 4]).on('zoom', (e) => g.attr('transform', e.transform)) as any)
|
||||||
|
|
||||||
|
const ecoleMap = new Map<string, EcoleData>(props.data.ecoles.map(e => [e.id, e]))
|
||||||
|
|
||||||
|
const ecoleNodes: any[] = props.data.ecoles.map(e => ({
|
||||||
|
id: `ecole-${e.id}`, type: 'ecole', ecoleId: e.id, label: e.label, color: e.color, r: 38,
|
||||||
|
x: W * e.x_hint, y: H * e.y_hint, fx: W * e.x_hint, fy: H * e.y_hint,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const auteurNodes: any[] = props.data.auteurs.map(a => ({
|
||||||
|
id: a.id, type: 'auteur', nom: a.nom, dates: a.dates, bio_courte: a.bio_courte,
|
||||||
|
ecole_principale: a.ecole_principale,
|
||||||
|
color: ecoleMap.get(a.ecole_principale)?.color ?? '#888', r: 11,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const allNodes = [...ecoleNodes, ...auteurNodes]
|
||||||
|
const links: any[] = []
|
||||||
|
props.data.auteurs.forEach(a => {
|
||||||
|
links.push({ source: a.id, target: `ecole-${a.ecole_principale}`, strength: 0.65 })
|
||||||
|
a.ecoles.filter(e => e !== a.ecole_principale).forEach(e => links.push({ source: a.id, target: `ecole-${e}`, strength: 0.25 }))
|
||||||
|
})
|
||||||
|
|
||||||
|
if (simulation) simulation.stop()
|
||||||
|
simulation = d3.forceSimulation(allNodes)
|
||||||
|
.force('link', d3.forceLink(links).id((d: any) => d.id).distance(90).strength((d: any) => d.strength ?? 0.5))
|
||||||
|
.force('charge', d3.forceManyBody().strength(-80))
|
||||||
|
.force('center', d3.forceCenter(W / 2, H / 2))
|
||||||
|
.force('collision', d3.forceCollide().radius((d: any) => d.r + 5))
|
||||||
|
|
||||||
|
d3LinkSel = g.append('g').selectAll('line').data(links).join('line')
|
||||||
|
.attr('stroke', 'rgba(150,150,150,0.3)').attr('stroke-width', 1.2)
|
||||||
|
|
||||||
|
d3NodeSel = g.append('g').selectAll('g').data(allNodes).join('g')
|
||||||
|
.style('cursor', (d: any) => d.type === 'auteur' ? 'pointer' : 'default')
|
||||||
|
.call(d3.drag<any, any>()
|
||||||
|
.on('start', (e: any, d: any) => { if (!e.active) simulation.alphaTarget(0.3).restart(); d.fx = d.x; d.fy = d.y })
|
||||||
|
.on('drag', (e: any, d: any) => { d.fx = e.x; d.fy = e.y })
|
||||||
|
.on('end', (e: any, d: any) => { if (!e.active) simulation.alphaTarget(0); if (d.type !== 'ecole') { d.fx = null; d.fy = null } }))
|
||||||
|
.on('click', (e: any, d: any) => { e.stopPropagation(); if (d.type === 'auteur') emit('select-auteur', d.id) })
|
||||||
|
|
||||||
|
d3NodeSel.append('circle')
|
||||||
|
.attr('r', (d: any) => d.r)
|
||||||
|
.attr('fill', (d: any) => d.type === 'ecole' ? d.color : d.color + 'cc')
|
||||||
|
.attr('stroke', (d: any) => d.type === 'ecole' ? 'rgba(255,255,255,0.6)' : d.color)
|
||||||
|
.attr('stroke-width', (d: any) => d.type === 'ecole' ? 3 : 1.5)
|
||||||
|
|
||||||
|
d3NodeSel.filter((d: any) => d.type === 'ecole').append('text')
|
||||||
|
.attr('text-anchor', 'middle').attr('dy', '0.35em').attr('font-size', '10px').attr('font-weight', '700').attr('fill', 'white')
|
||||||
|
.style('pointer-events', 'none')
|
||||||
|
.each(function(d: any) {
|
||||||
|
const el = d3.select(this as any)
|
||||||
|
const words: string[] = d.label.split(' ')
|
||||||
|
if (words.length <= 2) { el.text(d.label) } else {
|
||||||
|
const mid = Math.ceil(words.length / 2)
|
||||||
|
el.append('tspan').attr('x', 0).attr('dy', '-0.6em').text(words.slice(0, mid).join(' '))
|
||||||
|
el.append('tspan').attr('x', 0).attr('dy', '1.2em').text(words.slice(mid).join(' '))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
d3NodeSel.filter((d: any) => d.type === 'auteur').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)).attr('font-size', '9px').attr('font-weight', '500')
|
||||||
|
.style('pointer-events', 'none')
|
||||||
|
|
||||||
|
d3NodeSel.filter((d: any) => d.type === 'auteur')
|
||||||
|
.on('mouseenter', (e: any, d: any) => {
|
||||||
|
if (!tooltipRef.value) return
|
||||||
|
const bio = d.bio_courte.length > 90 ? d.bio_courte.slice(0, 87) + '...' : d.bio_courte
|
||||||
|
tooltipRef.value.innerHTML = `<strong>${d.nom}</strong> <span style="opacity:0.6;font-size:0.7rem;">${d.dates}</span><br><span style="opacity:0.75;font-size:0.72rem;">${bio}</span>`
|
||||||
|
tooltipRef.value.style.opacity = '1'
|
||||||
|
})
|
||||||
|
.on('mousemove', (e: any) => {
|
||||||
|
if (!tooltipRef.value || !svgEl) return
|
||||||
|
const rect = (svgEl as HTMLElement).getBoundingClientRect()
|
||||||
|
tooltipRef.value.style.left = (e.clientX - rect.left + 14) + 'px'
|
||||||
|
tooltipRef.value.style.top = (e.clientY - rect.top - 10) + 'px'
|
||||||
|
})
|
||||||
|
.on('mouseleave', () => { if (tooltipRef.value) tooltipRef.value.style.opacity = '0' })
|
||||||
|
|
||||||
|
simulation.on('tick', () => {
|
||||||
|
d3LinkSel.attr('x1', (d: any) => d.source.x).attr('y1', (d: any) => d.source.y)
|
||||||
|
.attr('x2', (d: any) => d.target.x).attr('y2', (d: any) => d.target.y)
|
||||||
|
d3NodeSel.attr('transform', (d: any) => `translate(${d.x},${d.y})`)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => props.active, (val) => { if (val && import.meta.client && props.data) requestAnimationFrame(() => requestAnimationFrame(() => initGraph())) })
|
||||||
|
watch(() => props.data, (val) => { if (val && props.active && import.meta.client) requestAnimationFrame(() => requestAnimationFrame(() => initGraph())) })
|
||||||
|
onMounted(async () => { if (import.meta.client && props.data && props.active) { await nextTick(); initGraph() } })
|
||||||
|
onUnmounted(() => { if (simulation) simulation.stop() })
|
||||||
|
|
||||||
|
// Expose pour reset D3 apres resize du conteneur
|
||||||
|
function triggerResize() {
|
||||||
|
if (simulation) {
|
||||||
|
simulation.alpha(0.3).restart()
|
||||||
|
} else if (import.meta.client && props.data && props.active) {
|
||||||
|
initGraph()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
defineExpose({ triggerResize })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.pensees-auteur-label { fill: var(--nav-text); opacity: 1; paint-order: stroke; stroke: var(--nav-bg); stroke-width: 2px; stroke-linejoin: round; user-select: none; font-weight: 600; }
|
||||||
|
</style>
|
||||||
296
components/ChatbotPensees.vue
Normal file
296
components/ChatbotPensees.vue
Normal file
@@ -0,0 +1,296 @@
|
|||||||
|
<template>
|
||||||
|
<!-- 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>
|
||||||
|
|
||||||
|
<!-- Corpus toggle overlay -->
|
||||||
|
<div class="shrink-0 px-3 pt-2 pb-1" style="background:var(--nav-bg);border-bottom:1px solid var(--nav-bg-alt);">
|
||||||
|
<div class="flex gap-1" role="group" aria-label="Choisir le corpus">
|
||||||
|
<button v-for="opt in corpusOptions" :key="opt.value" @click="setCorpus(opt.value)" :title="opt.tooltip"
|
||||||
|
class="flex-1 px-2 py-1 rounded text-xs font-medium transition-colors"
|
||||||
|
:style="corpus === opt.value ? 'background:var(--nav-primary);color:var(--nav-text-on-primary);' : 'background:var(--nav-bg-alt);color:var(--nav-text-muted);'"
|
||||||
|
:aria-pressed="corpus === opt.value">{{ opt.label }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Messages overlay -->
|
||||||
|
<div ref="msgElOverlay" class="flex-1 overflow-y-auto px-4 py-3 flex flex-col gap-3" style="min-height:0;">
|
||||||
|
<div v-if="messages.length === 0" style="font-size:0.8rem;color:var(--nav-text-muted);line-height:1.5;">
|
||||||
|
<template v-if="corpus === 'pensees'">Pose une question sur les pensees ecologiques...</template>
|
||||||
|
<template v-else-if="corpus === 'projets'">Pose une question sur les projets d'architecture de Jules...</template>
|
||||||
|
<template v-else>Pose une question sur les pensees ecologiques ancrees dans les projets archi de Jules.</template>
|
||||||
|
</div>
|
||||||
|
<template v-for="(msg, i) in messages" :key="i">
|
||||||
|
<div v-if="msg.role === 'user'" class="self-end max-w-[85%] px-3 py-2 rounded-xl text-sm"
|
||||||
|
style="background:var(--nav-primary);color:var(--nav-text-on-primary);font-weight:500;">{{ msg.content }}</div>
|
||||||
|
<div v-else class="self-start max-w-full">
|
||||||
|
<div class="px-3 py-2 rounded-xl text-sm leading-relaxed" style="background:var(--nav-bg-alt);color:var(--nav-text);"
|
||||||
|
v-html="renderMd(stripSrc(msg.content))" />
|
||||||
|
<div v-if="filteredSources(msg.content).length" class="mt-1.5">
|
||||||
|
<button @click="toggled[i] = !toggled[i]" class="flex items-center gap-1 text-xs hover:opacity-70" style="color:var(--nav-text-muted);">
|
||||||
|
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"
|
||||||
|
:style="`transform:rotate(${toggled[i] ? 90 : 0}deg);transition:transform 0.15s`"><polyline points="9 18 15 12 9 6"/></svg>
|
||||||
|
Sources ({{ filteredSources(msg.content).length }})
|
||||||
|
</button>
|
||||||
|
<div v-if="toggled[i]" class="mt-1 flex flex-col gap-1">
|
||||||
|
<div v-for="(s, si) in filteredSources(msg.content)" :key="si" class="px-2 py-1 rounded text-xs"
|
||||||
|
style="background:var(--nav-bg-alt);color:var(--nav-text-muted);border-left:2px solid var(--nav-primary-solid);">
|
||||||
|
<span style="font-weight:600;color:var(--nav-text);">[{{ si + 1 }}]</span> {{ s }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div v-if="loading" class="self-start px-3 py-2 rounded-xl" style="background:var(--nav-bg-alt);">
|
||||||
|
<span class="dots"><span/><span style="animation-delay:150ms"/><span style="animation-delay:300ms"/></span>
|
||||||
|
</div>
|
||||||
|
<div v-if="err" class="text-xs px-3 py-2 rounded-xl" style="background:#fee;color:#c0392b;">{{ err }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Input overlay -->
|
||||||
|
<div class="shrink-0 px-3 py-3" style="border-top:1px solid var(--nav-bg-alt);">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<input ref="inputElOverlay" v-model="q" type="text" placeholder="Ta question..." maxlength="500"
|
||||||
|
class="flex-1 px-3 py-2 rounded-lg text-sm outline-none"
|
||||||
|
style="background:var(--nav-bg);color:var(--nav-text);border:1px solid var(--nav-bg-alt);"
|
||||||
|
@keydown.enter.prevent="send" />
|
||||||
|
<button @click="send" :disabled="loading || !q.trim()"
|
||||||
|
class="flex items-center justify-center w-9 h-9 rounded-lg"
|
||||||
|
:style="loading||!q.trim() ? 'background:var(--nav-bg-alt);opacity:0.5;cursor:not-allowed;' : 'background:var(--nav-primary);cursor:pointer;'"
|
||||||
|
aria-label="Envoyer">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="color:white;">
|
||||||
|
<line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<!-- 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 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="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 inline -->
|
||||||
|
<div class="shrink-0 px-3 py-3" style="border-top:1px solid var(--nav-bg-alt);">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<input ref="inputElInline" v-model="q" type="text" placeholder="Ta question..." maxlength="500"
|
||||||
|
class="flex-1 px-3 py-2 rounded-lg text-sm outline-none"
|
||||||
|
style="background:var(--nav-bg);color:var(--nav-text);border:1px solid var(--nav-bg-alt);"
|
||||||
|
@keydown.enter.prevent="send" />
|
||||||
|
<button @click="send" :disabled="loading || !q.trim()"
|
||||||
|
class="flex items-center justify-center w-9 h-9 rounded-lg"
|
||||||
|
:style="loading||!q.trim() ? 'background:var(--nav-bg-alt);opacity:0.5;cursor:not-allowed;' : 'background:var(--nav-primary);cursor:pointer;'"
|
||||||
|
aria-label="Envoyer">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="color:white;">
|
||||||
|
<line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
interface Message { role: 'user' | 'assistant'; content: string }
|
||||||
|
|
||||||
|
type CorpusMode = 'pensees' | 'projets' | 'both'
|
||||||
|
|
||||||
|
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
|
||||||
|
inline?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const open = ref(false)
|
||||||
|
const q = ref('')
|
||||||
|
const messages = ref<Message[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
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
|
||||||
|
|
||||||
|
const corpus = ref<CorpusMode>('both')
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
const saved = window.localStorage.getItem(CORPUS_STORAGE_KEY) as CorpusMode | null
|
||||||
|
if (saved && ['pensees', 'projets', 'both'].includes(saved)) {
|
||||||
|
corpus.value = saved
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function 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
|
||||||
|
err.value = ''
|
||||||
|
messages.value.push({ role: 'user', content: query })
|
||||||
|
q.value = ''
|
||||||
|
loading.value = true
|
||||||
|
await nextTick()
|
||||||
|
scrollBottom()
|
||||||
|
try {
|
||||||
|
const res = await $fetch<{ response: string }>('/api/chatbot-pensees', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { query, mode: 'hybrid', corpus: corpus.value },
|
||||||
|
})
|
||||||
|
messages.value.push({ role: 'assistant', content: res.response ?? '' })
|
||||||
|
} 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()
|
||||||
|
scrollBottom()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
.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>
|
||||||
@@ -52,18 +52,10 @@
|
|||||||
<div class="chatbot-body-inner" ref="messagesContainer">
|
<div class="chatbot-body-inner" ref="messagesContainer">
|
||||||
<!-- Onboarding -->
|
<!-- Onboarding -->
|
||||||
<div v-if="messages.length === 0" class="onboarding-bubble">
|
<div v-if="messages.length === 0" class="onboarding-bubble">
|
||||||
<p>Ce chatbot fonctionne sur un serveur européen souverain
|
<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>
|
||||||
(Mistral FR, zéro rétention), conçu sobre en énergie.</p>
|
<p>Décris ta situation, je te propose les fiches les plus pertinentes.</p>
|
||||||
<p>Pour m'aider à te répondre efficacement,
|
<p class="example">Exemple : "Architecte salarié, litige avec mon employeur, besoin d'un appui juridique droit du travail, Île-de-France."</p>
|
||||||
formule ta requête ainsi :</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>
|
||||||
<ul>
|
|
||||||
<li>• Besoin : [ce que tu cherches]</li>
|
|
||||||
<li>• Thématique : [juridique / technique / économique / ...]</li>
|
|
||||||
<li>• Lieu : [région ou ville]</li>
|
|
||||||
</ul>
|
|
||||||
<p class="example">Exemple : "Je suis salarié d'agence, litige avec mon
|
|
||||||
employeur, besoin conseil juridique droit du travail,
|
|
||||||
Île-de-France."</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Messages -->
|
<!-- Messages -->
|
||||||
@@ -72,7 +64,7 @@ employeur, besoin conseil juridique droit du travail,
|
|||||||
<div v-else class="assistant-bubble">
|
<div v-else class="assistant-bubble">
|
||||||
<p>{{ msg.content }}</p>
|
<p>{{ msg.content }}</p>
|
||||||
<div v-if="msg.fiches && msg.fiches.length > 0" class="fiches-list">
|
<div v-if="msg.fiches && msg.fiches.length > 0" class="fiches-list">
|
||||||
<p class="fiches-title">Fiches recommandées :</p>
|
<p class="fiches-title">Fiches recommandees :</p>
|
||||||
<a
|
<a
|
||||||
v-for="fiche in msg.fiches"
|
v-for="fiche in msg.fiches"
|
||||||
:key="fiche.id"
|
:key="fiche.id"
|
||||||
@@ -83,6 +75,21 @@ employeur, besoin conseil juridique droit du travail,
|
|||||||
<span v-if="fiche.explication" class="fiche-expl">{{ fiche.explication }}</span>
|
<span v-if="fiche.explication" class="fiche-expl">{{ fiche.explication }}</span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="msg.suggestedHashtags && msg.suggestedHashtags.length" style="margin-top: 8px;">
|
||||||
|
<p style="font-size: 0.7rem; color: var(--nav-text-muted); margin-bottom: 4px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em;">Filtrer par :</p>
|
||||||
|
<div style="display: flex; flex-wrap: wrap; gap: 4px;">
|
||||||
|
<span
|
||||||
|
v-for="tag in msg.suggestedHashtags"
|
||||||
|
:key="tag"
|
||||||
|
style="
|
||||||
|
padding: 2px 8px; border-radius: 9999px; font-size: 0.7rem; cursor: pointer;
|
||||||
|
background: var(--nav-bg-alt); color: var(--nav-text); border: 1px solid var(--nav-bg-alt);
|
||||||
|
transition: all 0.15s;
|
||||||
|
"
|
||||||
|
@click="emit('applyHashtag', tag)"
|
||||||
|
>{{ tag }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -132,10 +139,12 @@ interface ChatMessage {
|
|||||||
role: 'user' | 'assistant'
|
role: 'user' | 'assistant'
|
||||||
content: string
|
content: string
|
||||||
fiches?: FicheReco[]
|
fiches?: FicheReco[]
|
||||||
|
suggestedHashtags?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
'highlightOrgs': [ids: (number | string)[]]
|
'highlightOrgs': [ids: (number | string)[]]
|
||||||
|
'applyHashtag': [tag: string]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const isExpanded = ref(false)
|
const isExpanded = ref(false)
|
||||||
@@ -145,6 +154,37 @@ const loading = ref(false)
|
|||||||
const errorMsg = ref('')
|
const errorMsg = ref('')
|
||||||
const messagesContainer = ref<HTMLElement | null>(null)
|
const messagesContainer = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
|
// Detection hashtags depuis la question posee
|
||||||
|
const HASHTAG_KEYWORDS: Record<string, string[]> = {
|
||||||
|
'#reemploi-structurel': ['reemploi', 'materiaux recuperes', 'deconstruction', 'reemploi structurel'],
|
||||||
|
'#reemploi-second-oeuvre': ['revetement', 'second oeuvre', 'reemploi'],
|
||||||
|
'#biosource-geosource': ['biosource', 'geosource', 'paille', 'terre', 'chanvre', 'lin', 'biosource'],
|
||||||
|
'#low-tech-experimentation': ['low-tech', 'low tech', 'technique simple', 'autonomie', 'lowtech'],
|
||||||
|
'#chantier-ecole': ['formation', 'chantier ecole', 'chantier-ecole', 'apprendre', 'auto-construction', 'autoconstruction'],
|
||||||
|
'#sobriete-energetique': ['sobriete', 'energie', 'renovation energetique', 'isolation', 'chauffage', 'economie energie'],
|
||||||
|
'#mal-logement-precarite': ['mal-logement', 'precarite', 'sans-abri', 'logement social', 'squat', 'mal logement'],
|
||||||
|
'#tiers-lieux-friches': ['friche', 'tiers-lieu', 'tiers lieu', 'espace intermediaire', 'temporaire', 'reconversion'],
|
||||||
|
'#accompagnement-cooperatif': ['cooperative', 'accompagnement', 'cooperation', 'collectif', 'mutualisation'],
|
||||||
|
'#transition-energetique-territoriale': ['territoire', 'transition', 'energetique', 'local', 'region', 'transition energetique'],
|
||||||
|
'#communs-fonciers': ['communs', 'foncier', 'anti-speculatif', 'community land trust', 'commun foncier'],
|
||||||
|
'#hack-juridique': ['juridique', 'montage', 'structure legale', 'sci', 'cooperative', 'statut'],
|
||||||
|
'#retrofit-strates': ['retrofit', 'renovation lourde', 'sur-isolation', 'rehaussement'],
|
||||||
|
'#phytoconstruction': ['plantes', 'vegetal', 'arbre', 'construction vivante', 'phyto'],
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectHashtagsFromQuery(query: string): string[] {
|
||||||
|
const q = query.toLowerCase()
|
||||||
|
.normalize('NFD')
|
||||||
|
.replace(/[̀-ͯ]/g, '')
|
||||||
|
const detected: string[] = []
|
||||||
|
for (const [hashtag, keywords] of Object.entries(HASHTAG_KEYWORDS)) {
|
||||||
|
if (keywords.some(kw => q.includes(kw))) {
|
||||||
|
detected.push(hashtag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return detected.slice(0, 3)
|
||||||
|
}
|
||||||
|
|
||||||
function toggleExpand() {
|
function toggleExpand() {
|
||||||
isExpanded.value = !isExpanded.value
|
isExpanded.value = !isExpanded.value
|
||||||
}
|
}
|
||||||
@@ -170,10 +210,12 @@ async function sendMessage() {
|
|||||||
body: { question },
|
body: { question },
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const suggestedHashtags = detectHashtagsFromQuery(question)
|
||||||
const assistantMsg: ChatMessage = {
|
const assistantMsg: ChatMessage = {
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
content: res.reponse_texte,
|
content: res.reponse_texte,
|
||||||
fiches: res.fiches_recommandees || [],
|
fiches: res.fiches_recommandees || [],
|
||||||
|
suggestedHashtags: suggestedHashtags.length ? suggestedHashtags : undefined,
|
||||||
}
|
}
|
||||||
messages.value.push(assistantMsg)
|
messages.value.push(assistantMsg)
|
||||||
|
|
||||||
|
|||||||
208
components/ChatbotReseaux.vue
Normal file
208
components/ChatbotReseaux.vue
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<transition name="backdrop">
|
||||||
|
<div
|
||||||
|
v-if="modelValue"
|
||||||
|
class="fixed inset-0 z-[1010]"
|
||||||
|
style="background: rgba(26,34,56,0.5);"
|
||||||
|
@click="emit('update:modelValue', false)"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</transition>
|
||||||
|
|
||||||
|
<transition name="sheet">
|
||||||
|
<div
|
||||||
|
v-if="modelValue"
|
||||||
|
class="fixed inset-x-0 bottom-0 z-[1011] flex flex-col"
|
||||||
|
style="background: var(--nav-surface); height: 100dvh; max-height: 100dvh; box-shadow: 0 -4px 32px rgba(26,34,56,0.18);"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="Assistant Réseaux AEP"
|
||||||
|
>
|
||||||
|
<div class="flex justify-center pt-3 pb-1 shrink-0">
|
||||||
|
<div class="rounded-full" style="width: 36px; height: 4px; background: var(--nav-bg-alt);" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between px-4 py-3 shrink-0 border-b" style="border-color: var(--nav-bg-alt);">
|
||||||
|
<button
|
||||||
|
@click="emit('update:modelValue', false)"
|
||||||
|
class="flex items-center gap-2 text-sm font-medium transition-opacity hover:opacity-70"
|
||||||
|
style="color: var(--nav-text-muted);"
|
||||||
|
aria-label="Retour"
|
||||||
|
>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<polyline points="15 18 9 12 15 6"/>
|
||||||
|
</svg>
|
||||||
|
Retour
|
||||||
|
</button>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div class="w-7 h-7 rounded-full flex items-center justify-center shrink-0" style="background: var(--nav-primary);">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color: var(--nav-text-on-primary);">
|
||||||
|
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<span class="font-bold text-sm" style="color: var(--nav-text);">Réseaux AEP</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div ref="messagesContainer" class="flex-1 overflow-y-auto px-4 py-4 flex flex-col gap-3">
|
||||||
|
<div v-if="messages.length === 0" class="onboarding-bubble">
|
||||||
|
<p>Je connais les <strong>120 réseaux, collectifs et agences</strong> cartographiés dans AEP — ceux qui portent une vision écologique et politique de l'architecture.</p>
|
||||||
|
<p>Décris ta situation : tu cherches un collectif, une agence inspirante, un partenaire sur un projet en Occitanie, en transition énergétique ?</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-for="(msg, i) in messages" :key="i">
|
||||||
|
<div v-if="msg.role === 'user'" class="user-bubble">{{ msg.content }}</div>
|
||||||
|
<div v-else class="assistant-bubble">
|
||||||
|
<div v-html="renderMd(msg.content)" />
|
||||||
|
<div v-if="msg.fiches && msg.fiches.length > 0" class="fiches-list" style="margin-top:12px;">
|
||||||
|
<p class="fiches-title">Structures recommandées :</p>
|
||||||
|
<a
|
||||||
|
v-for="fiche in msg.fiches"
|
||||||
|
:key="fiche.id"
|
||||||
|
:href="`/agences#${fiche.id}`"
|
||||||
|
class="fiche-card"
|
||||||
|
>
|
||||||
|
<span class="fiche-nom">{{ fiche.nom }}</span>
|
||||||
|
<span v-if="fiche.explication" class="fiche-expl">{{ fiche.explication }}</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div v-if="loading" class="assistant-bubble loading-bubble">
|
||||||
|
<span class="dot" /><span class="dot" /><span class="dot" />
|
||||||
|
</div>
|
||||||
|
<div v-if="errorMsg" class="error-bubble">{{ errorMsg }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="shrink-0 px-4 pt-3 border-t" style="border-color: var(--nav-bg-alt); padding-bottom: max(1rem, env(safe-area-inset-bottom));">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
v-model="inputText"
|
||||||
|
type="text"
|
||||||
|
:disabled="loading"
|
||||||
|
placeholder="Décris ta situation…"
|
||||||
|
class="flex-1 px-4 py-3 rounded-xl text-sm border"
|
||||||
|
style="border-color: var(--nav-bg-alt); background: var(--nav-bg); color: var(--nav-text); font-family: var(--nav-font); font-size: 16px;"
|
||||||
|
@keydown.enter.prevent="sendMessage"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
:disabled="loading || !inputText.trim()"
|
||||||
|
class="w-11 h-11 rounded-xl flex items-center justify-center shrink-0 transition-opacity"
|
||||||
|
style="background: var(--nav-primary);"
|
||||||
|
:style="{ opacity: (loading || !inputText.trim()) ? 0.4 : 1 }"
|
||||||
|
aria-label="Envoyer"
|
||||||
|
@click="sendMessage"
|
||||||
|
>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="color: var(--nav-text-on-primary);">
|
||||||
|
<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>
|
||||||
|
</transition>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useMarkdown } from '~/composables/useMarkdown'
|
||||||
|
const { render: renderMd } = useMarkdown()
|
||||||
|
|
||||||
|
interface FicheReco { id: number | string; nom: string; explication?: string }
|
||||||
|
interface ChatMessage { role: 'user' | 'assistant'; content: string; fiches?: FicheReco[] }
|
||||||
|
|
||||||
|
const props = defineProps<{ modelValue: boolean }>()
|
||||||
|
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
|
||||||
|
|
||||||
|
const messages = ref<ChatMessage[]>([])
|
||||||
|
const inputText = ref('')
|
||||||
|
const loading = ref(false)
|
||||||
|
const errorMsg = ref('')
|
||||||
|
const messagesContainer = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
|
watch(() => props.modelValue, (open) => {
|
||||||
|
if (typeof document === 'undefined') return
|
||||||
|
document.body.style.overflow = open ? 'hidden' : ''
|
||||||
|
document.documentElement.style.overflow = open ? 'hidden' : ''
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (typeof document !== 'undefined') {
|
||||||
|
document.body.style.overflow = ''
|
||||||
|
document.documentElement.style.overflow = ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
async function sendMessage() {
|
||||||
|
const question = inputText.value.trim()
|
||||||
|
if (!question || loading.value) return
|
||||||
|
inputText.value = ''
|
||||||
|
errorMsg.value = ''
|
||||||
|
messages.value.push({ role: 'user', content: question })
|
||||||
|
loading.value = true
|
||||||
|
await nextTick()
|
||||||
|
if (messagesContainer.value) messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await $fetch<{ reponse_texte: string; fiches_recommandees: FicheReco[] }>(
|
||||||
|
'/api/chatbot-reseaux',
|
||||||
|
{ method: 'POST', body: { question } }
|
||||||
|
)
|
||||||
|
messages.value.push({ role: 'assistant', content: res.reponse_texte, fiches: res.fiches_recommandees || [] })
|
||||||
|
} catch (e: any) {
|
||||||
|
const s = e?.statusCode ?? e?.status
|
||||||
|
errorMsg.value = s === 429
|
||||||
|
? 'Limite de 20 questions par jour atteinte.'
|
||||||
|
: 'Une erreur est survenue. Réessaie dans quelques instants.'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
await nextTick()
|
||||||
|
if (messagesContainer.value) messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.backdrop-enter-active, .backdrop-leave-active { transition: opacity 0.2s ease; }
|
||||||
|
.backdrop-enter-from, .backdrop-leave-to { opacity: 0; }
|
||||||
|
.sheet-enter-active, .sheet-leave-active { transition: transform 0.3s cubic-bezier(0.32, 0.72, 0, 1); }
|
||||||
|
.sheet-enter-from, .sheet-leave-to { transform: translateY(100%); }
|
||||||
|
|
||||||
|
.onboarding-bubble {
|
||||||
|
background: var(--nav-bg); border: 1px solid var(--nav-bg-alt);
|
||||||
|
border-radius: 12px; padding: 16px;
|
||||||
|
font-size: 0.85rem; line-height: 1.65; color: var(--nav-text-muted);
|
||||||
|
}
|
||||||
|
.onboarding-bubble p { margin-bottom: 10px; }
|
||||||
|
.onboarding-bubble strong { font-weight: 700; color: var(--nav-text); }
|
||||||
|
|
||||||
|
.user-bubble {
|
||||||
|
align-self: flex-end; max-width: 80%;
|
||||||
|
background: var(--nav-primary); color: var(--nav-text-on-primary);
|
||||||
|
border-radius: 16px 16px 4px 16px; padding: 10px 14px;
|
||||||
|
font-size: 0.875rem; line-height: 1.5;
|
||||||
|
}
|
||||||
|
.assistant-bubble {
|
||||||
|
align-self: flex-start; max-width: 90%;
|
||||||
|
background: var(--nav-surface); border: 1px solid var(--nav-bg-alt);
|
||||||
|
border-radius: 16px 16px 16px 4px; padding: 12px 14px;
|
||||||
|
font-size: 0.875rem; line-height: 1.6; color: var(--nav-text);
|
||||||
|
}
|
||||||
|
.loading-bubble { display: flex; gap: 5px; align-items: center; }
|
||||||
|
.dot { width: 7px; height: 7px; border-radius: 50%; background: var(--nav-text-muted); animation: blink 1.2s infinite; }
|
||||||
|
.dot:nth-child(2) { animation-delay: 0.2s; }
|
||||||
|
.dot:nth-child(3) { animation-delay: 0.4s; }
|
||||||
|
@keyframes blink { 0%,80%,100% { opacity: 0.3; } 40% { opacity: 1; } }
|
||||||
|
|
||||||
|
.error-bubble { align-self: flex-start; max-width: 90%; color: #a85d3e; font-size: 0.8rem; padding: 8px 12px; border-radius: 8px; background: rgba(168,93,62,0.08); }
|
||||||
|
|
||||||
|
.fiches-list { display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.fiches-title { font-size: 0.75rem; font-weight: 600; color: var(--nav-text-muted); text-transform: uppercase; letter-spacing: 0.04em; margin-bottom: 4px; }
|
||||||
|
.fiche-card { display: block; background: var(--nav-bg); border: 1px solid var(--nav-bg-alt); border-radius: 8px; padding: 8px 12px; text-decoration: none; transition: background 0.15s; }
|
||||||
|
.fiche-card:hover { background: var(--nav-bg-alt); }
|
||||||
|
.fiche-nom { display: block; font-size: 0.875rem; font-weight: 600; color: var(--nav-text); }
|
||||||
|
.fiche-expl { display: block; font-size: 0.8rem; color: var(--nav-text-muted); margin-top: 2px; }
|
||||||
|
</style>
|
||||||
@@ -69,18 +69,14 @@
|
|||||||
<div ref="messagesContainer" class="flex-1 overflow-y-auto px-4 py-4 flex flex-col gap-3">
|
<div ref="messagesContainer" class="flex-1 overflow-y-auto px-4 py-4 flex flex-col gap-3">
|
||||||
<!-- Message onboarding (avant la première question) -->
|
<!-- Message onboarding (avant la première question) -->
|
||||||
<div v-if="messages.length === 0" class="onboarding-bubble">
|
<div v-if="messages.length === 0" class="onboarding-bubble">
|
||||||
<p>Ce chatbot fonctionne sur un serveur européen souverain
|
<p>Ce chatbot fonctionne sur un serveur européen souverain (Mistral FR, zéro rétention), conçu sobre en énergie.</p>
|
||||||
(Mistral FR, zéro rétention), conçu sobre en énergie.</p>
|
<p>Pour m'aider à te répondre efficacement, formule ta requête ainsi :</p>
|
||||||
<p>Pour m'aider à te répondre efficacement,
|
|
||||||
formule ta requête ainsi :</p>
|
|
||||||
<ul>
|
<ul>
|
||||||
<li>• Besoin : [ce que tu cherches]</li>
|
<li>• Besoin : [ce que tu cherches]</li>
|
||||||
<li>• Thématique : [juridique / technique / économique / ...]</li>
|
<li>• Thématique : [juridique / technique / économique / ...]</li>
|
||||||
<li>• Lieu : [région ou ville]</li>
|
<li>• Lieu : [région ou ville]</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p class="example">Exemple : "Je suis salarié d'agence, litige avec mon
|
<p class="example">Exemple : "Je suis salarié d'agence, litige avec mon employeur, besoin conseil juridique droit du travail, Île-de-France."</p>
|
||||||
employeur, besoin conseil juridique droit du travail,
|
|
||||||
Île-de-France."</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Messages -->
|
<!-- Messages -->
|
||||||
@@ -92,7 +88,7 @@ employeur, besoin conseil juridique droit du travail,
|
|||||||
|
|
||||||
<!-- Message assistant -->
|
<!-- Message assistant -->
|
||||||
<div v-else class="assistant-bubble">
|
<div v-else class="assistant-bubble">
|
||||||
<p>{{ msg.content }}</p>
|
<div class="md-content" v-html="renderMd(msg.content)" />
|
||||||
|
|
||||||
<!-- Fiches recommandées -->
|
<!-- Fiches recommandées -->
|
||||||
<div v-if="msg.fiches && msg.fiches.length > 0" class="fiches-list">
|
<div v-if="msg.fiches && msg.fiches.length > 0" class="fiches-list">
|
||||||
@@ -164,6 +160,9 @@ employeur, besoin conseil juridique droit du travail,
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { useMarkdown } from '~/composables/useMarkdown'
|
||||||
|
const { render: renderMd } = useMarkdown()
|
||||||
|
|
||||||
interface FicheReco {
|
interface FicheReco {
|
||||||
id: number | string
|
id: number | string
|
||||||
nom: string
|
nom: string
|
||||||
@@ -320,7 +319,17 @@ function scrollToBottom() {
|
|||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
color: var(--nav-text);
|
color: var(--nav-text);
|
||||||
}
|
}
|
||||||
.assistant-bubble p { margin: 0; }
|
.assistant-bubble > p { margin: 0; }
|
||||||
|
|
||||||
|
/* Markdown rendu via v-html — :deep() perce le scoped */
|
||||||
|
:deep(.md-content) { font-size: inherit; line-height: 1.6; }
|
||||||
|
:deep(.md-content p) { margin: 0 0 0.4em; }
|
||||||
|
:deep(.md-content p:last-child) { margin-bottom: 0; }
|
||||||
|
:deep(.md-content strong) { font-weight: 700; }
|
||||||
|
:deep(.md-content em) { font-style: italic; }
|
||||||
|
:deep(.md-content ul) { margin: 0.3em 0 0.3em 1.1em; list-style: disc; padding: 0; }
|
||||||
|
:deep(.md-content li) { margin-bottom: 0.15em; }
|
||||||
|
:deep(.md-content a) { text-decoration: underline; opacity: 0.8; }
|
||||||
|
|
||||||
/* Fiches recommandées */
|
/* Fiches recommandées */
|
||||||
.fiches-list {
|
.fiches-list {
|
||||||
|
|||||||
@@ -1,38 +1,16 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1">
|
||||||
<p class="text-xs font-bold uppercase tracking-widest" style="color: var(--nav-text-muted);">Échelle</p>
|
<p class="filter-label">ÉCHELLE</p>
|
||||||
<!-- Inline sur 1 ligne — même pattern que FonctionFilter -->
|
<div class="chips-row">
|
||||||
<div class="flex flex-wrap gap-x-4 gap-y-1.5">
|
<span
|
||||||
<label
|
|
||||||
v-for="option in ECHELLES"
|
v-for="option in ECHELLES"
|
||||||
:key="option"
|
:key="option"
|
||||||
class="flex items-center gap-1.5 cursor-pointer select-none transition-opacity"
|
class="chip"
|
||||||
>
|
:style="isSelected(option)
|
||||||
<!-- Case carrée -->
|
? 'background: var(--nav-primary); color: var(--nav-text-on-primary); font-weight: 600;'
|
||||||
<span
|
: 'background: var(--nav-bg-alt); color: var(--nav-text-muted);'"
|
||||||
class="flex items-center justify-center shrink-0 transition-all"
|
@click="toggle(option)"
|
||||||
style="width: 18px; height: 18px; border: 1.5px solid; border-radius: 3px;"
|
>{{ option }}</span>
|
||||||
: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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -61,3 +39,24 @@ function toggle(option: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</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>
|
||||||
|
|||||||
98
components/FicheAuteur.vue
Normal file
98
components/FicheAuteur.vue
Normal 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>
|
||||||
284
components/FicheFamilleModal.vue
Normal file
284
components/FicheFamilleModal.vue
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<!-- Backdrop -->
|
||||||
|
<Transition name="backdrop">
|
||||||
|
<div
|
||||||
|
v-if="modelValue && familleId != null"
|
||||||
|
class="fixed inset-0 z-[1400]"
|
||||||
|
style="background: rgba(26,34,56,0.55);"
|
||||||
|
@click="close"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</Transition>
|
||||||
|
|
||||||
|
<!-- Modal -->
|
||||||
|
<Transition name="modal">
|
||||||
|
<div
|
||||||
|
v-if="modelValue && familleId != null"
|
||||||
|
class="fixed z-[1401] top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 flex flex-col"
|
||||||
|
style="
|
||||||
|
width: min(720px, 94vw);
|
||||||
|
max-height: 88vh;
|
||||||
|
background: var(--nav-bg);
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow: 0 16px 64px rgba(26,34,56,0.28);
|
||||||
|
overflow: hidden;
|
||||||
|
"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
:aria-label="familleLabel"
|
||||||
|
@keydown.esc="close"
|
||||||
|
>
|
||||||
|
<!-- Header : background couleur famille -->
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-between px-5 py-4 shrink-0"
|
||||||
|
:style="`background: ${familleColor}; color: white;`"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div
|
||||||
|
class="w-3 h-3 rounded-full shrink-0"
|
||||||
|
style="background: white;"
|
||||||
|
/>
|
||||||
|
<h2 class="text-lg font-bold" style="color: white;">{{ familleLabel }}</h2>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
@click="close"
|
||||||
|
class="w-8 h-8 rounded-lg flex items-center justify-center transition-colors"
|
||||||
|
style="background: rgba(255,255,255,0.18); color: white;"
|
||||||
|
aria-label="Fermer"
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" aria-hidden="true">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Body scrollable -->
|
||||||
|
<div class="flex-1 overflow-y-auto px-5 py-5">
|
||||||
|
<!-- Description longue editoriale -->
|
||||||
|
<p
|
||||||
|
class="text-sm leading-relaxed mb-5"
|
||||||
|
style="color: var(--nav-text); white-space: pre-wrap;"
|
||||||
|
>{{ familleDescription }}</p>
|
||||||
|
|
||||||
|
<!-- Separateur -->
|
||||||
|
<div style="height: 1px; background: var(--nav-bg-alt); margin-bottom: 16px;" />
|
||||||
|
|
||||||
|
<!-- Mode fusion : Principal+Secondaire melanges (peu de secondaires) -->
|
||||||
|
<template v-if="!showSplit">
|
||||||
|
<h3
|
||||||
|
class="text-xs font-bold uppercase tracking-wide mb-3"
|
||||||
|
style="color: var(--nav-text-muted);"
|
||||||
|
>{{ allStructures.length }} structure{{ allStructures.length > 1 ? 's' : '' }}</h3>
|
||||||
|
<ul style="list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 4px;">
|
||||||
|
<li
|
||||||
|
v-for="s in allStructures"
|
||||||
|
:key="s.id"
|
||||||
|
@click="selectStructure(s.id)"
|
||||||
|
class="structure-row"
|
||||||
|
style="
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
padding: 8px 10px; border-radius: 6px;
|
||||||
|
cursor: pointer; transition: background 0.1s;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style="width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0;"
|
||||||
|
:style="`background: ${FAMILLE_COLORS[s.famille_principale] ?? '#888'};`"
|
||||||
|
:title="`Famille principale : ${FAMILLE_LABELS[s.famille_principale] ?? ''}`"
|
||||||
|
/>
|
||||||
|
<span class="text-sm font-medium" style="color: var(--nav-text);">{{ s.nom }}</span>
|
||||||
|
<span class="text-xs" style="color: var(--nav-text-muted);">{{ s.ville }}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Mode split : Principal / Secondaire separes -->
|
||||||
|
<template v-else>
|
||||||
|
<div v-if="principalStructures.length" class="mb-5">
|
||||||
|
<h3
|
||||||
|
class="text-xs font-bold uppercase tracking-wide mb-3"
|
||||||
|
style="color: var(--nav-text-muted);"
|
||||||
|
>Principal ({{ principalStructures.length }})</h3>
|
||||||
|
<ul style="list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 4px;">
|
||||||
|
<li
|
||||||
|
v-for="s in principalStructures"
|
||||||
|
:key="s.id"
|
||||||
|
@click="selectStructure(s.id)"
|
||||||
|
class="structure-row"
|
||||||
|
style="
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
padding: 8px 10px; border-radius: 6px;
|
||||||
|
cursor: pointer; transition: background 0.1s;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style="width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0;"
|
||||||
|
:style="`background: ${FAMILLE_COLORS[s.famille_principale] ?? '#888'};`"
|
||||||
|
/>
|
||||||
|
<span class="text-sm font-medium" style="color: var(--nav-text);">{{ s.nom }}</span>
|
||||||
|
<span class="text-xs" style="color: var(--nav-text-muted);">{{ s.ville }}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="secondaireStructures.length">
|
||||||
|
<h3
|
||||||
|
class="text-xs font-bold uppercase tracking-wide mb-3"
|
||||||
|
style="color: var(--nav-text-muted);"
|
||||||
|
>Secondaire ({{ secondaireStructures.length }})</h3>
|
||||||
|
<ul style="list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 4px;">
|
||||||
|
<li
|
||||||
|
v-for="s in secondaireStructures"
|
||||||
|
:key="s.id"
|
||||||
|
@click="selectStructure(s.id)"
|
||||||
|
class="structure-row"
|
||||||
|
style="
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
padding: 8px 10px; border-radius: 6px;
|
||||||
|
cursor: pointer; transition: background 0.1s;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style="width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0;"
|
||||||
|
:style="`background: ${FAMILLE_COLORS[s.famille_principale] ?? '#888'};`"
|
||||||
|
:title="`Famille principale : ${FAMILLE_LABELS[s.famille_principale] ?? ''}`"
|
||||||
|
/>
|
||||||
|
<span class="text-sm font-medium" style="color: var(--nav-text);">{{ s.nom }}</span>
|
||||||
|
<span class="text-xs" style="color: var(--nav-text-muted);">{{ s.ville }}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<div
|
||||||
|
class="px-5 py-3 shrink-0 text-xs"
|
||||||
|
style="border-top: 1px solid var(--nav-bg-alt); color: var(--nav-text-muted); background: var(--nav-surface);"
|
||||||
|
>
|
||||||
|
Click sur une structure pour ouvrir sa fiche
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { ReseauxBifurcationData, StructureV2 } from '~/types/structure-v2'
|
||||||
|
|
||||||
|
const FAMILLE_COLORS: Record<number, string> = {
|
||||||
|
1: '#a85d3e',
|
||||||
|
2: '#c4a472',
|
||||||
|
3: '#d4a017',
|
||||||
|
4: '#5a7a4a',
|
||||||
|
5: '#3d6a8c',
|
||||||
|
6: '#6b3fa0',
|
||||||
|
}
|
||||||
|
|
||||||
|
const FAMILLE_LABELS: Record<number, string> = {
|
||||||
|
1: 'Reemploi & filieres',
|
||||||
|
2: 'Frugalite & low-tech',
|
||||||
|
3: 'Architecture sociale',
|
||||||
|
4: 'Collectifs & AMO',
|
||||||
|
5: 'Urbanisme de transition',
|
||||||
|
6: 'Recherche-action',
|
||||||
|
}
|
||||||
|
|
||||||
|
const FAMILLE_DESCRIPTIONS: Record<number, string> = {
|
||||||
|
1: "Structures dont le geste premier est de travailler avec la matiere existante : deconstruction selective, plateformes de redistribution, filieres biosourcees et geosourcees.",
|
||||||
|
2: "Pratiques qui partent du principe qu'on peut faire mieux avec moins. Renovation profonde, materiaux locaux, sobriete choisie.",
|
||||||
|
3: "Structures dont le terrain premier est le mal-logement, la precarite, l'hospitalite. Architecture comme reponse a l'urgence sociale.",
|
||||||
|
4: "Structures qui accompagnent les projets collectifs : cooperatives d'habitat, ecovillages, accompagnement vers l'autogestion ou la renovation.",
|
||||||
|
5: "Demarches a l'echelle du territoire : villes en transition, PLU alternatifs, coalitions territoriales.",
|
||||||
|
6: "Recherche-action et production de contre-savoirs (Forensic Architecture, Rural Studio, PEROU, Centrala). Badge transversal aux familles.",
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: boolean
|
||||||
|
familleId: number | null
|
||||||
|
data: ReseauxBifurcationData | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: boolean]
|
||||||
|
'select-structure': [id: string]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
emit('update:modelValue', false)
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectStructure(id: string) {
|
||||||
|
// Fermer d'abord pour eviter superposition de modales
|
||||||
|
emit('update:modelValue', false)
|
||||||
|
emit('select-structure', id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fermeture Esc globale
|
||||||
|
onMounted(() => {
|
||||||
|
const handler = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape' && props.modelValue) close()
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', handler)
|
||||||
|
onUnmounted(() => window.removeEventListener('keydown', handler))
|
||||||
|
})
|
||||||
|
|
||||||
|
const familleColor = computed(() =>
|
||||||
|
FAMILLE_COLORS[props.familleId ?? 0] ?? '#888'
|
||||||
|
)
|
||||||
|
|
||||||
|
const familleLabel = computed(() =>
|
||||||
|
FAMILLE_LABELS[props.familleId ?? 0] ?? ''
|
||||||
|
)
|
||||||
|
|
||||||
|
const familleDescription = computed(() =>
|
||||||
|
FAMILLE_DESCRIPTIONS[props.familleId ?? 0] ?? ''
|
||||||
|
)
|
||||||
|
|
||||||
|
const principalStructures = computed<StructureV2[]>(() => {
|
||||||
|
if (!props.data || props.familleId == null) return []
|
||||||
|
return props.data.structures
|
||||||
|
.filter(s => s.famille_principale === props.familleId)
|
||||||
|
.sort((a, b) => a.nom.localeCompare(b.nom, 'fr'))
|
||||||
|
})
|
||||||
|
|
||||||
|
const secondaireStructures = computed<StructureV2[]>(() => {
|
||||||
|
if (!props.data || props.familleId == null) return []
|
||||||
|
return props.data.structures
|
||||||
|
.filter(s =>
|
||||||
|
s.famille_principale !== props.familleId
|
||||||
|
&& (s.familles_secondaires ?? []).includes(props.familleId as number)
|
||||||
|
)
|
||||||
|
.sort((a, b) => a.nom.localeCompare(b.nom, 'fr'))
|
||||||
|
})
|
||||||
|
|
||||||
|
const allStructures = computed<StructureV2[]>(() => {
|
||||||
|
return [...principalStructures.value, ...secondaireStructures.value]
|
||||||
|
})
|
||||||
|
|
||||||
|
// Heuristique : si > 3 secondaires, separer en sections distinctes
|
||||||
|
const showSplit = computed(() => secondaireStructures.value.length > 3)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.structure-row:hover {
|
||||||
|
background: var(--nav-bg-alt);
|
||||||
|
}
|
||||||
|
|
||||||
|
.backdrop-enter-active, .backdrop-leave-active { transition: opacity 0.2s ease; }
|
||||||
|
.backdrop-enter-from, .backdrop-leave-to { opacity: 0; }
|
||||||
|
|
||||||
|
.modal-enter-active, .modal-leave-active {
|
||||||
|
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||||
|
}
|
||||||
|
.modal-enter-from, .modal-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translate(-50%, -52%);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.backdrop-enter-active, .backdrop-leave-active { transition: none; }
|
||||||
|
.modal-enter-active, .modal-leave-active { transition: none; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -15,10 +15,9 @@
|
|||||||
<Transition name="modal">
|
<Transition name="modal">
|
||||||
<div
|
<div
|
||||||
v-if="modelValue && orgId != null"
|
v-if="modelValue && orgId != null"
|
||||||
class="fixed z-[1501] top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 flex flex-col"
|
class="fiche-modal fixed z-[1501] left-1/2 -translate-x-1/2 flex flex-col"
|
||||||
style="
|
style="
|
||||||
width: min(768px, 92vw);
|
width: min(768px, 92vw);
|
||||||
max-height: 90vh;
|
|
||||||
background: var(--nav-bg);
|
background: var(--nav-bg);
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
box-shadow: 0 16px 64px rgba(26,34,56,0.28);
|
box-shadow: 0 16px 64px rgba(26,34,56,0.28);
|
||||||
@@ -144,6 +143,21 @@ function onCommentSubmitted() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
/* Modal positionnement : centré desktop, descendu sous le header sur mobile */
|
||||||
|
.fiche-modal {
|
||||||
|
top: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
max-height: 90vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1023px) {
|
||||||
|
.fiche-modal {
|
||||||
|
top: 76px;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
max-height: calc(100dvh - 92px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Backdrop */
|
/* Backdrop */
|
||||||
.backdrop-enter-active, .backdrop-leave-active { transition: opacity 0.2s ease; }
|
.backdrop-enter-active, .backdrop-leave-active { transition: opacity 0.2s ease; }
|
||||||
.backdrop-enter-from, .backdrop-leave-to { opacity: 0; }
|
.backdrop-enter-from, .backdrop-leave-to { opacity: 0; }
|
||||||
@@ -156,6 +170,11 @@ function onCommentSubmitted() {
|
|||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translate(-50%, -52%);
|
transform: translate(-50%, -52%);
|
||||||
}
|
}
|
||||||
|
@media (max-width: 1023px) {
|
||||||
|
.modal-enter-from, .modal-leave-to {
|
||||||
|
transform: translate(-50%, calc(-2% + 76px));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.backdrop-enter-active, .backdrop-leave-active { transition: none; }
|
.backdrop-enter-active, .backdrop-leave-active { transition: none; }
|
||||||
|
|||||||
360
components/FicheModalV2.vue
Normal file
360
components/FicheModalV2.vue
Normal file
@@ -0,0 +1,360 @@
|
|||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<!-- Backdrop -->
|
||||||
|
<Transition name="backdrop">
|
||||||
|
<div
|
||||||
|
v-if="modelValue && structureId != null"
|
||||||
|
class="fixed inset-0 z-[1500]"
|
||||||
|
style="background: rgba(26,34,56,0.55);"
|
||||||
|
@click="close"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</Transition>
|
||||||
|
|
||||||
|
<!-- Modal -->
|
||||||
|
<Transition name="modal">
|
||||||
|
<div
|
||||||
|
v-if="modelValue && structureId != null && structure"
|
||||||
|
class="fiche-modal-v2 fixed z-[1501] left-1/2 -translate-x-1/2 flex flex-col"
|
||||||
|
style="
|
||||||
|
width: min(780px, 94vw);
|
||||||
|
background: var(--nav-bg);
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow: 0 16px 64px rgba(26,34,56,0.28);
|
||||||
|
overflow: hidden;
|
||||||
|
"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
:aria-label="structure?.nom ?? 'Fiche structure'"
|
||||||
|
@keydown.esc="close"
|
||||||
|
>
|
||||||
|
<!-- Header modal -->
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-between px-5 py-3 shrink-0"
|
||||||
|
:style="`border-bottom: 3px solid ${familleColor}; background: var(--nav-surface);`"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<!-- Pastille famille -->
|
||||||
|
<div
|
||||||
|
class="w-3 h-3 rounded-full shrink-0"
|
||||||
|
:style="`background: ${familleColor};`"
|
||||||
|
/>
|
||||||
|
<span class="text-sm font-semibold" style="color: var(--nav-text-muted);">
|
||||||
|
{{ familleLabel }}
|
||||||
|
</span>
|
||||||
|
<!-- Badges -->
|
||||||
|
<div class="flex gap-1.5">
|
||||||
|
<span
|
||||||
|
v-if="structure.badges.centre_ressources"
|
||||||
|
class="px-2 py-0.5 rounded-full text-xs font-medium"
|
||||||
|
style="background: #2d8a6b22; color: #2d8a6b;"
|
||||||
|
>Centre ressources</span>
|
||||||
|
<span
|
||||||
|
v-if="structure.badges.mouvement_manifeste"
|
||||||
|
class="px-2 py-0.5 rounded-full text-xs font-medium"
|
||||||
|
style="background: #c44a2f22; color: #c44a2f;"
|
||||||
|
>Manifeste</span>
|
||||||
|
<span
|
||||||
|
v-if="structure.badges.contre_pouvoir_spatial"
|
||||||
|
class="px-2 py-0.5 rounded-full text-xs font-medium"
|
||||||
|
style="background: #1a3a6b22; color: #1a3a6b;"
|
||||||
|
>Contre-pouvoir</span>
|
||||||
|
<span
|
||||||
|
v-if="structure.badges.f6_recherche_politique"
|
||||||
|
class="px-2 py-0.5 rounded-full text-xs font-medium"
|
||||||
|
style="background: #6b3fa022; color: #6b3fa0;"
|
||||||
|
>Recherche pol.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<a
|
||||||
|
v-if="structure.url"
|
||||||
|
:href="structure.url"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-opacity hover:opacity-70"
|
||||||
|
style="background: var(--nav-bg-alt); color: var(--nav-text);"
|
||||||
|
>
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
|
||||||
|
<polyline points="15 3 21 3 21 9"/>
|
||||||
|
<line x1="10" y1="14" x2="21" y2="3"/>
|
||||||
|
</svg>
|
||||||
|
Site web
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
@click="close"
|
||||||
|
class="w-8 h-8 rounded-lg flex items-center justify-center transition-colors"
|
||||||
|
style="background: var(--nav-bg-alt); color: var(--nav-text-muted);"
|
||||||
|
aria-label="Fermer"
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" aria-hidden="true">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Contenu scrollable -->
|
||||||
|
<div class="flex-1 overflow-y-auto px-5 py-5">
|
||||||
|
<!-- Nom + meta -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<h2 class="text-xl font-bold mb-1" style="color: var(--nav-text);">{{ structure.nom }}</h2>
|
||||||
|
<div class="flex flex-wrap gap-2 text-sm" style="color: var(--nav-text-muted);">
|
||||||
|
<span>{{ structure.type_principal }}</span>
|
||||||
|
<span>·</span>
|
||||||
|
<span>{{ structure.ville }}, {{ structure.pays }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Hashtags -->
|
||||||
|
<div v-if="structure.hashtags.length" class="flex flex-wrap gap-1.5 mb-4">
|
||||||
|
<span
|
||||||
|
v-for="tag in structure.hashtags"
|
||||||
|
:key="tag"
|
||||||
|
class="px-2 py-0.5 rounded-full text-xs"
|
||||||
|
style="background: var(--nav-bg-alt); color: var(--nav-text-muted);"
|
||||||
|
>{{ tag }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Description courte -->
|
||||||
|
<p class="text-sm leading-relaxed mb-4" style="color: var(--nav-text);">{{ structure.description_courte }}</p>
|
||||||
|
|
||||||
|
<!-- Description longue (expandable) -->
|
||||||
|
<div v-if="structure.description_longue" class="mb-4">
|
||||||
|
<div
|
||||||
|
class="text-sm leading-relaxed"
|
||||||
|
style="color: var(--nav-text); white-space: pre-wrap;"
|
||||||
|
:style="showFullDesc ? '' : 'max-height: 120px; overflow: hidden;'"
|
||||||
|
>{{ structure.description_longue }}</div>
|
||||||
|
<button
|
||||||
|
@click="showFullDesc = !showFullDesc"
|
||||||
|
class="mt-2 text-xs underline"
|
||||||
|
style="color: var(--nav-text-muted);"
|
||||||
|
>{{ showFullDesc ? 'Réduire' : 'Lire la suite…' }}</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pensées rattachées -->
|
||||||
|
<div v-if="structure.pensees && structure.pensees.length" class="mb-4">
|
||||||
|
<h3 class="text-xs font-bold uppercase tracking-wide mb-2" style="color: var(--nav-text-muted);">Pensées rattachées</h3>
|
||||||
|
<div class="flex flex-wrap gap-1.5">
|
||||||
|
<span
|
||||||
|
v-for="pensee in structure.pensees"
|
||||||
|
:key="pensee.id"
|
||||||
|
class="px-2 py-0.5 rounded text-xs"
|
||||||
|
:style="pensee.confiance === 'ia_suggested'
|
||||||
|
? 'background: var(--nav-bg-alt); color: var(--nav-text-muted); border: 1px dashed var(--nav-bg-alt);'
|
||||||
|
: 'background: var(--nav-bg-alt); color: var(--nav-text);'"
|
||||||
|
:title="pensee.confiance === 'ia_suggested' ? 'IA suggéré' : ''"
|
||||||
|
>
|
||||||
|
{{ pensee.label }}<span v-if="pensee.confiance === 'ia_suggested'" class="ml-1 opacity-60">~</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Projets emblématiques -->
|
||||||
|
<div v-if="projetsStructure.length" class="mb-4">
|
||||||
|
<h3 class="text-xs font-bold uppercase tracking-wide mb-2" style="color: var(--nav-text-muted);">Projets emblématiques</h3>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div
|
||||||
|
v-for="projet in projetsStructure.slice(0, 5)"
|
||||||
|
:key="projet.id"
|
||||||
|
class="rounded-lg p-3"
|
||||||
|
style="background: var(--nav-surface); border: 1px solid var(--nav-bg-alt);"
|
||||||
|
>
|
||||||
|
<div class="flex items-start justify-between gap-2">
|
||||||
|
<span class="font-medium text-sm" style="color: var(--nav-text);">{{ projet.nom }}</span>
|
||||||
|
<span v-if="projet.annee" class="text-xs shrink-0" style="color: var(--nav-text-muted);">{{ projet.annee }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="projet.lieu" class="text-xs mt-0.5" style="color: var(--nav-text-muted);">{{ projet.lieu }}</div>
|
||||||
|
<p class="text-xs mt-1 leading-relaxed" style="color: var(--nav-text-muted);">{{ projet.description.slice(0, 120) }}{{ projet.description.length > 120 ? '…' : '' }}</p>
|
||||||
|
<a
|
||||||
|
v-if="projet.url"
|
||||||
|
:href="projet.url"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="text-xs mt-1 inline-block"
|
||||||
|
style="color: var(--nav-text-muted); text-decoration: underline;"
|
||||||
|
>En savoir plus →</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Structures voisines (graphe) -->
|
||||||
|
<div v-if="structuresVoisines.length" class="mb-4">
|
||||||
|
<h3 class="text-xs font-bold uppercase tracking-wide mb-2" style="color: var(--nav-text-muted);">Structures liées</h3>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
v-for="voisine in structuresVoisines.slice(0, 6)"
|
||||||
|
:key="voisine.id"
|
||||||
|
class="px-2 py-1 rounded text-xs transition-colors hover:opacity-70"
|
||||||
|
style="background: var(--nav-bg-alt); color: var(--nav-text); border: 1px solid transparent;"
|
||||||
|
@click="emit('update:structureId', voisine.id)"
|
||||||
|
>{{ voisine.nom }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sources -->
|
||||||
|
<div v-if="structure.sources && structure.sources.length" class="mb-4">
|
||||||
|
<h3 class="text-xs font-bold uppercase tracking-wide mb-2" style="color: var(--nav-text-muted);">Sources</h3>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<div v-for="(source, i) in structure.sources" :key="i" class="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
class="px-1.5 py-0.5 rounded text-xs shrink-0"
|
||||||
|
style="background: var(--nav-bg-alt); color: var(--nav-text-muted);"
|
||||||
|
>{{ source.type }}</span>
|
||||||
|
<a
|
||||||
|
v-if="source.url"
|
||||||
|
:href="source.url"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="text-xs underline truncate"
|
||||||
|
style="color: var(--nav-text);"
|
||||||
|
>{{ source.titre }}</a>
|
||||||
|
<span v-else class="text-xs" style="color: var(--nav-text);">{{ source.titre }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- CTAs -->
|
||||||
|
<div class="flex gap-3 pt-2" style="border-top: 1px solid var(--nav-bg-alt);">
|
||||||
|
<a
|
||||||
|
href="/contribuer"
|
||||||
|
class="text-xs underline"
|
||||||
|
style="color: var(--nav-text-muted);"
|
||||||
|
>Signaler une erreur</a>
|
||||||
|
<a
|
||||||
|
href="/contribuer"
|
||||||
|
class="text-xs underline"
|
||||||
|
style="color: var(--nav-text-muted);"
|
||||||
|
>Réclamer cette fiche</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { ReseauxBifurcationData, StructureV2, ProjetEmblematique } from '~/types/structure-v2'
|
||||||
|
|
||||||
|
const FAMILLE_COLORS: Record<number, string> = {
|
||||||
|
1: '#a85d3e',
|
||||||
|
2: '#c4a472',
|
||||||
|
3: '#d4a017',
|
||||||
|
4: '#5a7a4a',
|
||||||
|
5: '#3d6a8c',
|
||||||
|
}
|
||||||
|
|
||||||
|
const FAMILLE_LABELS: Record<number, string> = {
|
||||||
|
1: 'Réemploi & filières',
|
||||||
|
2: 'Frugalité & low-tech',
|
||||||
|
3: 'Architecture sociale',
|
||||||
|
4: 'Collectifs & AMO',
|
||||||
|
5: 'Urbanisme de transition',
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: boolean
|
||||||
|
structureId: string | null
|
||||||
|
data: ReseauxBifurcationData | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: boolean]
|
||||||
|
'update:structureId': [id: string]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const showFullDesc = ref(false)
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
emit('update:modelValue', false)
|
||||||
|
showFullDesc.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fermeture Esc globale
|
||||||
|
onMounted(() => {
|
||||||
|
const handler = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape' && props.modelValue) close()
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', handler)
|
||||||
|
onUnmounted(() => window.removeEventListener('keydown', handler))
|
||||||
|
})
|
||||||
|
|
||||||
|
// Remettre showFullDesc a false a chaque changement de fiche
|
||||||
|
watch(() => props.structureId, () => {
|
||||||
|
showFullDesc.value = false
|
||||||
|
})
|
||||||
|
|
||||||
|
const structure = computed<StructureV2 | null>(() => {
|
||||||
|
if (!props.data || !props.structureId) return null
|
||||||
|
return props.data.structures.find(s => s.id === props.structureId) ?? null
|
||||||
|
})
|
||||||
|
|
||||||
|
const familleColor = computed(() =>
|
||||||
|
FAMILLE_COLORS[structure.value?.famille_principale ?? 1] ?? '#888'
|
||||||
|
)
|
||||||
|
|
||||||
|
const familleLabel = computed(() =>
|
||||||
|
FAMILLE_LABELS[structure.value?.famille_principale ?? 1] ?? ''
|
||||||
|
)
|
||||||
|
|
||||||
|
const projetsStructure = computed<ProjetEmblematique[]>(() => {
|
||||||
|
if (!props.data || !props.structureId) return []
|
||||||
|
return props.data.projets?.filter(p => p.structure_parent === props.structureId) ?? []
|
||||||
|
})
|
||||||
|
|
||||||
|
const structuresVoisines = computed<StructureV2[]>(() => {
|
||||||
|
if (!props.data || !props.structureId) return []
|
||||||
|
const edges = props.data.graphe?.edges ?? []
|
||||||
|
const voisineIds = edges
|
||||||
|
.filter(e => e.source === props.structureId || e.target === props.structureId)
|
||||||
|
.sort((a, b) => (b.score ?? 0) - (a.score ?? 0))
|
||||||
|
.map(e => e.source === props.structureId ? e.target : e.source)
|
||||||
|
.slice(0, 8)
|
||||||
|
|
||||||
|
return voisineIds
|
||||||
|
.map(id => props.data!.structures.find(s => s.id === id))
|
||||||
|
.filter(Boolean) as StructureV2[]
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* Backdrop */
|
||||||
|
.backdrop-enter-active, .backdrop-leave-active { transition: opacity 0.2s ease; }
|
||||||
|
.backdrop-enter-from, .backdrop-leave-to { opacity: 0; }
|
||||||
|
|
||||||
|
/* Modal positionnement : centré desktop, descendu sous le header sur mobile */
|
||||||
|
.fiche-modal-v2 {
|
||||||
|
top: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
max-height: 90vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1023px) {
|
||||||
|
.fiche-modal-v2 {
|
||||||
|
top: 76px;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
max-height: calc(100dvh - 92px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal */
|
||||||
|
.modal-enter-active, .modal-leave-active {
|
||||||
|
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||||
|
}
|
||||||
|
.modal-enter-from, .modal-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translate(-50%, -52%);
|
||||||
|
}
|
||||||
|
@media (max-width: 1023px) {
|
||||||
|
.modal-enter-from, .modal-leave-to {
|
||||||
|
transform: translate(-50%, calc(-2% + 76px));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.backdrop-enter-active, .backdrop-leave-active { transition: none; }
|
||||||
|
.modal-enter-active, .modal-leave-active { transition: none; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,35 +1,33 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1">
|
||||||
<p class="text-xs font-bold uppercase tracking-widest" style="color: var(--nav-text-muted);">Fonction</p>
|
<!-- Label + toggle collapse -->
|
||||||
<div class="space-y-1">
|
<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
|
<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"
|
v-for="fn in FONCTIONS"
|
||||||
:key="fn"
|
: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)"
|
@click="toggle(fn)"
|
||||||
:aria-pressed="modelValue.includes(fn)"
|
>{{ fn }}</span>
|
||||||
class="flex items-center gap-2.5 w-full rounded px-1 py-0.5 transition-all text-left hover:opacity-80"
|
|
||||||
:style="modelValue.includes(fn) ? 'background: rgba(26,34,56,0.06);' : ''"
|
|
||||||
>
|
|
||||||
<!-- Case : affiche le rang de priorité si actif, sinon le nombre d'orgs -->
|
|
||||||
<span
|
|
||||||
class="flex items-center justify-center shrink-0 text-xs font-bold transition-all"
|
|
||||||
style="width: 24px; height: 24px; border: 1.5px solid; border-radius: 4px;"
|
|
||||||
:style="modelValue.includes(fn)
|
|
||||||
? 'background: var(--nav-primary); border-color: var(--nav-primary); color: var(--nav-text-on-primary);'
|
|
||||||
: 'background: var(--nav-bg-alt); border-color: var(--nav-bg-alt); color: var(--nav-text-muted);'"
|
|
||||||
>
|
|
||||||
{{ modelValue.includes(fn) ? (modelValue.indexOf(fn) + 1) : (counts[fn] ?? 0) }}
|
|
||||||
</span>
|
|
||||||
<!-- Label -->
|
|
||||||
<span
|
|
||||||
class="text-sm leading-tight"
|
|
||||||
:style="modelValue.includes(fn) ? 'color: var(--nav-text); font-weight: 600;' : 'color: var(--nav-text);'"
|
|
||||||
>{{ fn }}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
</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);">
|
<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="underline hover:opacity-70">Effacer</button>
|
||||||
<button @click="emit('update:modelValue', [])" class="ml-2 underline hover:opacity-70">Effacer</button>
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -57,6 +55,25 @@ const emit = defineEmits<{
|
|||||||
'update:modelValue': [value: string[]]
|
'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) {
|
function toggle(fn: string) {
|
||||||
if (props.modelValue.includes(fn)) {
|
if (props.modelValue.includes(fn)) {
|
||||||
emit('update:modelValue', props.modelValue.filter(f => f !== fn))
|
emit('update:modelValue', props.modelValue.filter(f => f !== fn))
|
||||||
@@ -65,3 +82,23 @@ function toggle(fn: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</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>
|
||||||
|
|||||||
887
components/GraphView.vue
Normal file
887
components/GraphView.vue
Normal file
@@ -0,0 +1,887 @@
|
|||||||
|
<template>
|
||||||
|
<div class="graph-view" style="width: 100%; height: 100%; position: relative; background: var(--nav-bg);">
|
||||||
|
<!-- Canvas SVG pour D3 (zone centrale, marge droite pour sidebar) -->
|
||||||
|
<svg
|
||||||
|
ref="svgRef"
|
||||||
|
:style="{
|
||||||
|
width: sidebarOpen ? 'calc(100% - 200px)' : 'calc(100% - 40px)',
|
||||||
|
height: '100%',
|
||||||
|
transition: 'width 0.2s ease',
|
||||||
|
}"
|
||||||
|
></svg>
|
||||||
|
|
||||||
|
<!-- Sidebar droite (repliable) - 3 sections : AFFICHER / HASHTAGS / MODE D'EMPLOI -->
|
||||||
|
<aside
|
||||||
|
:style="{
|
||||||
|
position: 'absolute', top: '0', right: '0', bottom: '0',
|
||||||
|
width: sidebarOpen ? '200px' : '40px',
|
||||||
|
background: 'var(--nav-surface)',
|
||||||
|
borderLeft: '1px solid var(--nav-bg-alt)',
|
||||||
|
display: 'flex', flexDirection: 'column',
|
||||||
|
transition: 'width 0.2s ease',
|
||||||
|
zIndex: 10,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<!-- Toggle (toujours visible) -->
|
||||||
|
<button
|
||||||
|
@click="sidebarOpen = !sidebarOpen"
|
||||||
|
:title="sidebarOpen ? 'Replier la sidebar' : 'Deplier la sidebar'"
|
||||||
|
style="
|
||||||
|
width: 100%; height: 36px; flex-shrink: 0;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
background: var(--nav-bg-alt); border: none; cursor: pointer;
|
||||||
|
color: var(--nav-text-muted); font-size: 0.78rem; font-weight: 700;
|
||||||
|
border-bottom: 1px solid var(--nav-bg-alt);
|
||||||
|
"
|
||||||
|
>{{ sidebarOpen ? '>>' : '<<' }}</button>
|
||||||
|
|
||||||
|
<!-- Mode replie : label vertical -->
|
||||||
|
<div
|
||||||
|
v-if="!sidebarOpen"
|
||||||
|
style="
|
||||||
|
flex: 1; display: flex; align-items: center; justify-content: center;
|
||||||
|
writing-mode: vertical-rl; transform: rotate(180deg);
|
||||||
|
font-size: 0.7rem; font-weight: 700; color: var(--nav-text-muted);
|
||||||
|
letter-spacing: 0.12em; text-transform: uppercase;
|
||||||
|
"
|
||||||
|
>HASHTAGS ({{ activeHashtags.length }}/{{ props.allHashtags.length }})</div>
|
||||||
|
|
||||||
|
<!-- Mode deplie : 3 sections empilees -->
|
||||||
|
<template v-if="sidebarOpen">
|
||||||
|
<div style="flex: 1; overflow-y: auto; display: flex; flex-direction: column;">
|
||||||
|
|
||||||
|
<!-- SECTION 1 : AFFICHER (toggles familles / pratiques) -->
|
||||||
|
<div style="padding: 10px 12px; flex-shrink: 0;">
|
||||||
|
<div style="font-size: 0.72rem; font-weight: 700; color: var(--nav-text); text-transform: uppercase; letter-spacing: 0.08em; margin-bottom: 8px;">Afficher</div>
|
||||||
|
<label
|
||||||
|
:style="{
|
||||||
|
display: 'flex', alignItems: 'center', gap: '8px',
|
||||||
|
padding: '7px 10px', marginBottom: '4px',
|
||||||
|
borderRadius: '6px', cursor: 'pointer',
|
||||||
|
fontSize: '0.82rem', fontWeight: 600,
|
||||||
|
background: showFamilles ? 'var(--nav-bg-alt)' : 'transparent',
|
||||||
|
color: showFamilles ? 'var(--nav-text)' : 'var(--nav-text-muted)',
|
||||||
|
transition: 'all 0.12s',
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<input type="checkbox" v-model="showFamilles" style="cursor: pointer; width: 14px; height: 14px;" />
|
||||||
|
<span>Familles</span>
|
||||||
|
</label>
|
||||||
|
<label
|
||||||
|
:style="{
|
||||||
|
display: 'flex', alignItems: 'center', gap: '8px',
|
||||||
|
padding: '7px 10px',
|
||||||
|
borderRadius: '6px', cursor: 'pointer',
|
||||||
|
fontSize: '0.82rem', fontWeight: 600,
|
||||||
|
background: showPratiques ? 'var(--nav-bg-alt)' : 'transparent',
|
||||||
|
color: showPratiques ? 'var(--nav-text)' : 'var(--nav-text-muted)',
|
||||||
|
transition: 'all 0.12s',
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<input type="checkbox" v-model="showPratiques" style="cursor: pointer; width: 14px; height: 14px;" />
|
||||||
|
<span>Pratiques</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- SECTION 2 : HASHTAGS (chips groupees) -->
|
||||||
|
<div style="border-top: 1px solid var(--nav-bg-alt); margin-top: 0; padding: 10px 12px 8px; flex-shrink: 0;">
|
||||||
|
<div style="display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 6px;">
|
||||||
|
<span style="font-size: 0.72rem; font-weight: 700; color: var(--nav-text); text-transform: uppercase; letter-spacing: 0.08em;">Hashtags</span>
|
||||||
|
<span style="font-size: 0.68rem; color: var(--nav-text-muted);">{{ activeHashtags.length }} actif{{ activeHashtags.length > 1 ? 's' : '' }}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
v-if="activeHashtags.length"
|
||||||
|
@click="activeHashtags = []"
|
||||||
|
style="margin-bottom: 6px; font-size: 0.68rem; color: var(--nav-text-muted); background: none; border: none; cursor: pointer; text-decoration: underline; padding: 0;"
|
||||||
|
>Tout effacer</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="flex: 1; overflow-y: auto; padding: 0 10px 10px;">
|
||||||
|
<div
|
||||||
|
v-for="group in hashtagsByFamille"
|
||||||
|
:key="group.famille"
|
||||||
|
style="margin-bottom: 10px;"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
:style="{
|
||||||
|
fontSize: '0.65rem', fontWeight: 700,
|
||||||
|
color: group.color, textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.06em', marginBottom: '4px',
|
||||||
|
paddingLeft: '2px',
|
||||||
|
}"
|
||||||
|
>{{ group.label }}</div>
|
||||||
|
<div style="display: flex; flex-wrap: wrap; gap: 3px;">
|
||||||
|
<span
|
||||||
|
v-for="tag in group.tags"
|
||||||
|
:key="tag"
|
||||||
|
style="padding: 2px 7px; border-radius: 9999px; font-size: 0.66rem; cursor: pointer; transition: all 0.12s;"
|
||||||
|
:style="activeHashtags.includes(tag)
|
||||||
|
? `background: ${group.color}; color: white; font-weight: 600;`
|
||||||
|
: 'background: var(--nav-bg-alt); color: var(--nav-text-muted);'"
|
||||||
|
@click="toggleHashtag(tag)"
|
||||||
|
>{{ tag }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- SECTION 3 : MODE D'EMPLOI -->
|
||||||
|
<div style="border-top: 1px solid var(--nav-bg-alt); padding: 10px 12px; flex-shrink: 0;">
|
||||||
|
<div style="font-size: 0.72rem; font-weight: 700; color: var(--nav-text); text-transform: uppercase; letter-spacing: 0.08em; margin-bottom: 6px;">Mode d'emploi</div>
|
||||||
|
<div style="font-size: 0.7rem; color: var(--nav-text-muted); line-height: 1.5;">
|
||||||
|
La carte croise des familles editoriales avec des pratiques (hashtags). Coche les couches a afficher, filtre par hashtag, clique sur un noeud pour en savoir plus.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- Tooltip -->
|
||||||
|
<div ref="tooltipRef" style="
|
||||||
|
position: absolute; pointer-events: none;
|
||||||
|
background: var(--nav-surface); border: 1px solid var(--nav-bg-alt);
|
||||||
|
border-radius: 6px; padding: 8px 12px; font-size: 0.78rem;
|
||||||
|
color: var(--nav-text); max-width: 220px; box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
||||||
|
opacity: 0; transition: opacity 0.15s; z-index: 100;
|
||||||
|
"></div>
|
||||||
|
|
||||||
|
<!-- Popover unifie (famille OU hashtag) -->
|
||||||
|
<div
|
||||||
|
v-if="popover.open"
|
||||||
|
:style="{
|
||||||
|
position: 'absolute',
|
||||||
|
left: popover.x + 'px',
|
||||||
|
top: popover.y + 'px',
|
||||||
|
background: 'var(--nav-surface)',
|
||||||
|
border: '1px solid var(--nav-bg-alt)',
|
||||||
|
borderRadius: '8px',
|
||||||
|
padding: '12px 14px',
|
||||||
|
maxWidth: '280px',
|
||||||
|
boxShadow: '0 6px 18px rgba(0,0,0,0.18)',
|
||||||
|
zIndex: 50,
|
||||||
|
}"
|
||||||
|
@click.stop
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
@click="closePopover"
|
||||||
|
style="
|
||||||
|
position: absolute; top: 4px; right: 6px;
|
||||||
|
background: none; border: none; cursor: pointer;
|
||||||
|
font-size: 1rem; color: var(--nav-text-muted); padding: 2px 6px;
|
||||||
|
line-height: 1;
|
||||||
|
"
|
||||||
|
title="Fermer"
|
||||||
|
>x</button>
|
||||||
|
<div
|
||||||
|
:style="{
|
||||||
|
fontWeight: 700, fontSize: '0.92rem',
|
||||||
|
color: popover.color, marginBottom: '6px',
|
||||||
|
paddingRight: '14px',
|
||||||
|
}"
|
||||||
|
>{{ popover.title }}</div>
|
||||||
|
|
||||||
|
<!-- Body famille : description + compteur + 6 structures + bouton "Voir toutes" -->
|
||||||
|
<div v-if="popover.kind === 'famille'">
|
||||||
|
<div style="font-size: 0.78rem; line-height: 1.45; color: var(--nav-text); margin-bottom: 10px;">
|
||||||
|
{{ popover.body }}
|
||||||
|
</div>
|
||||||
|
<div style="font-size: 0.72rem; color: var(--nav-text-muted); margin-bottom: 6px;">
|
||||||
|
{{ popover.structures.length }} structure{{ popover.structures.length > 1 ? 's' : '' }} dans cette famille
|
||||||
|
</div>
|
||||||
|
<ul style="list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 3px;">
|
||||||
|
<li
|
||||||
|
v-for="s in popover.structures.slice(0, 6)"
|
||||||
|
:key="s.id"
|
||||||
|
@click="selectStructureFromPopover(s.id)"
|
||||||
|
style="
|
||||||
|
font-size: 0.78rem; color: var(--nav-text);
|
||||||
|
padding: 4px 6px; border-radius: 4px;
|
||||||
|
cursor: pointer; transition: background 0.1s;
|
||||||
|
display: flex; align-items: center; gap: 6px;
|
||||||
|
"
|
||||||
|
@mouseenter="(e: any) => e.currentTarget.style.background = 'var(--nav-bg-alt)'"
|
||||||
|
@mouseleave="(e: any) => e.currentTarget.style.background = 'transparent'"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style="width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0;"
|
||||||
|
:style="`background: ${popover.color};`"
|
||||||
|
/>
|
||||||
|
<span>{{ s.nom }}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<button
|
||||||
|
v-if="popover.familleId != null"
|
||||||
|
@click="openFicheFamilleFromPopover"
|
||||||
|
style="
|
||||||
|
margin-top: 10px; width: 100%;
|
||||||
|
padding: 7px 10px; border-radius: 6px;
|
||||||
|
background: var(--nav-bg-alt); border: none;
|
||||||
|
font-size: 0.75rem; font-weight: 600; cursor: pointer;
|
||||||
|
color: var(--nav-text); transition: opacity 0.12s;
|
||||||
|
text-align: left;
|
||||||
|
"
|
||||||
|
@mouseenter="(e: any) => e.currentTarget.style.opacity = '0.7'"
|
||||||
|
@mouseleave="(e: any) => e.currentTarget.style.opacity = '1'"
|
||||||
|
>Voir toutes les {{ popover.structures.length }} pratiques -></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Body hashtag : ligne generique + compteur + liste structures cliquables -->
|
||||||
|
<div v-else-if="popover.kind === 'hashtag'">
|
||||||
|
<div
|
||||||
|
style="
|
||||||
|
font-size: 0.72rem; color: var(--nav-text-muted);
|
||||||
|
font-style: italic; margin-bottom: 8px; line-height: 1.4;
|
||||||
|
"
|
||||||
|
>Pratique transversale - portee par {{ popover.structures.length }} structure{{ popover.structures.length > 1 ? 's' : '' }} de {{ popover.famillesCount }} famille{{ popover.famillesCount > 1 ? 's' : '' }}</div>
|
||||||
|
<ul style="list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 3px;">
|
||||||
|
<li
|
||||||
|
v-for="s in popover.structures.slice(0, 6)"
|
||||||
|
:key="s.id"
|
||||||
|
@click="selectStructureFromPopover(s.id)"
|
||||||
|
style="
|
||||||
|
font-size: 0.78rem; color: var(--nav-text);
|
||||||
|
padding: 4px 6px; border-radius: 4px;
|
||||||
|
cursor: pointer; transition: background 0.1s;
|
||||||
|
"
|
||||||
|
@mouseenter="(e: any) => e.currentTarget.style.background = 'var(--nav-bg-alt)'"
|
||||||
|
@mouseleave="(e: any) => e.currentTarget.style.background = 'transparent'"
|
||||||
|
>{{ s.nom }}</li>
|
||||||
|
</ul>
|
||||||
|
<div
|
||||||
|
v-if="popover.structures.length > 6"
|
||||||
|
style="font-size: 0.7rem; color: var(--nav-text-muted); margin-top: 6px; padding-left: 6px;"
|
||||||
|
>+ {{ popover.structures.length - 6 }} autre{{ popover.structures.length - 6 > 1 ? 's' : '' }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Fiche famille modale -->
|
||||||
|
<FicheFamilleModal
|
||||||
|
v-model="ficheFamilleOpen"
|
||||||
|
:famille-id="ficheFamilleId"
|
||||||
|
:data="props.data"
|
||||||
|
@select-structure="(id) => emit('select-structure', id)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { ReseauxBifurcationData } from '~/types/structure-v2'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
data: ReseauxBifurcationData | null
|
||||||
|
allHashtags: string[]
|
||||||
|
active?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'select-structure': [id: string]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const svgRef = ref<SVGElement | null>(null)
|
||||||
|
const tooltipRef = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
|
// Hashtag filter
|
||||||
|
const activeHashtags = ref<string[]>([])
|
||||||
|
const sidebarOpen = ref(true)
|
||||||
|
|
||||||
|
// Layers superposables (remplace viewMode exclusif PV2-5e)
|
||||||
|
const showFamilles = ref(true)
|
||||||
|
const showPratiques = ref(false)
|
||||||
|
|
||||||
|
function toggleHashtag(tag: string) {
|
||||||
|
activeHashtags.value = activeHashtags.value.includes(tag)
|
||||||
|
? activeHashtags.value.filter(t => t !== tag)
|
||||||
|
: [...activeHashtags.value, tag]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Popover unifie (famille | hashtag)
|
||||||
|
type PopoverState = {
|
||||||
|
open: boolean
|
||||||
|
kind: 'famille' | 'hashtag' | null
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
title: string
|
||||||
|
body: string
|
||||||
|
color: string
|
||||||
|
structures: { id: string; nom: string }[]
|
||||||
|
familleId: number | null
|
||||||
|
famillesCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const popover = ref<PopoverState>({
|
||||||
|
open: false,
|
||||||
|
kind: null,
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
title: '',
|
||||||
|
body: '',
|
||||||
|
color: '#000',
|
||||||
|
structures: [],
|
||||||
|
familleId: null,
|
||||||
|
famillesCount: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Fiche famille modale
|
||||||
|
const ficheFamilleOpen = ref(false)
|
||||||
|
const ficheFamilleId = ref<number | null>(null)
|
||||||
|
|
||||||
|
function closePopover() {
|
||||||
|
popover.value.open = false
|
||||||
|
popover.value.kind = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectStructureFromPopover(id: string) {
|
||||||
|
closePopover()
|
||||||
|
emit('select-structure', id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function openFicheFamilleFromPopover() {
|
||||||
|
if (popover.value.familleId == null) return
|
||||||
|
ficheFamilleId.value = popover.value.familleId
|
||||||
|
ficheFamilleOpen.value = true
|
||||||
|
closePopover()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mapping hashtag -> famille majoritaire
|
||||||
|
// En cas d'egalite : prendre la famille la plus petite (visibilite minoritaires)
|
||||||
|
const tagToFamille = computed<Record<string, number>>(() => {
|
||||||
|
if (!props.data) return {}
|
||||||
|
const counts: Record<string, Record<number, number>> = {}
|
||||||
|
props.data.structures.forEach(s => {
|
||||||
|
s.hashtags.forEach(tag => {
|
||||||
|
if (!counts[tag]) counts[tag] = {}
|
||||||
|
counts[tag][s.famille_principale] = (counts[tag][s.famille_principale] ?? 0) + 1
|
||||||
|
})
|
||||||
|
})
|
||||||
|
const familleSize: Record<number, number> = {}
|
||||||
|
props.data.structures.forEach(s => {
|
||||||
|
familleSize[s.famille_principale] = (familleSize[s.famille_principale] ?? 0) + 1
|
||||||
|
})
|
||||||
|
const out: Record<string, number> = {}
|
||||||
|
for (const tag in counts) {
|
||||||
|
const entries = Object.entries(counts[tag])
|
||||||
|
entries.sort((a, b) => {
|
||||||
|
const diff = (b[1] as number) - (a[1] as number)
|
||||||
|
if (diff !== 0) return diff
|
||||||
|
return (familleSize[Number(a[0])] ?? 0) - (familleSize[Number(b[0])] ?? 0)
|
||||||
|
})
|
||||||
|
out[tag] = Number(entries[0][0])
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
})
|
||||||
|
|
||||||
|
const hashtagsByFamille = computed(() => {
|
||||||
|
if (!props.data) return []
|
||||||
|
const map = tagToFamille.value
|
||||||
|
const groups: Record<number, string[]> = {}
|
||||||
|
props.allHashtags.forEach(tag => {
|
||||||
|
const fam = map[tag]
|
||||||
|
if (fam == null) return
|
||||||
|
if (!groups[fam]) groups[fam] = []
|
||||||
|
groups[fam].push(tag)
|
||||||
|
})
|
||||||
|
return [1, 2, 3, 4, 5, 6]
|
||||||
|
.filter(famId => groups[famId]?.length)
|
||||||
|
.map(famId => ({
|
||||||
|
famille: famId,
|
||||||
|
label: FAMILLE_LABELS[famId],
|
||||||
|
color: FAMILLE_COLORS[famId],
|
||||||
|
tags: groups[famId].sort(),
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
// Structures portant un hashtag donne (pour popover)
|
||||||
|
function structuresForHashtag(tag: string): { id: string; nom: string }[] {
|
||||||
|
if (!props.data) return []
|
||||||
|
return props.data.structures
|
||||||
|
.filter(s => s.hashtags.includes(tag))
|
||||||
|
.map(s => ({ id: s.id, nom: s.nom }))
|
||||||
|
}
|
||||||
|
|
||||||
|
// IDs de structures correspondant aux hashtags actifs
|
||||||
|
const filteredStructureIds = computed(() => {
|
||||||
|
if (!props.data || !activeHashtags.value.length) return null
|
||||||
|
const ids = new Set(
|
||||||
|
props.data.structures
|
||||||
|
.filter(s => activeHashtags.value.every(h => s.hashtags.includes(h)))
|
||||||
|
.map(s => s.id)
|
||||||
|
)
|
||||||
|
return ids
|
||||||
|
})
|
||||||
|
|
||||||
|
const FAMILLE_COLORS: Record<number, string> = {
|
||||||
|
1: '#a85d3e',
|
||||||
|
2: '#c4a472',
|
||||||
|
3: '#d4a017',
|
||||||
|
4: '#5a7a4a',
|
||||||
|
5: '#3d6a8c',
|
||||||
|
6: '#6b3fa0',
|
||||||
|
}
|
||||||
|
|
||||||
|
const FAMILLE_LABELS: Record<number, string> = {
|
||||||
|
1: 'Reemploi',
|
||||||
|
2: 'Frugalite',
|
||||||
|
3: 'Social',
|
||||||
|
4: 'Collectifs',
|
||||||
|
5: 'Urbanisme',
|
||||||
|
6: 'Recherche',
|
||||||
|
}
|
||||||
|
|
||||||
|
const FAMILLE_DESCRIPTIONS: Record<number, string> = {
|
||||||
|
1: "Structures dont le geste premier est de travailler avec la matiere existante : deconstruction selective, plateformes de redistribution, filieres biosourcees et geosourcees.",
|
||||||
|
2: "Pratiques qui partent du principe qu'on peut faire mieux avec moins. Renovation profonde, materiaux locaux, sobriete choisie.",
|
||||||
|
3: "Structures dont le terrain premier est le mal-logement, la precarite, l'hospitalite. Architecture comme reponse a l'urgence sociale.",
|
||||||
|
4: "Structures qui accompagnent les projets collectifs : cooperatives d'habitat, ecovillages, accompagnement vers l'autogestion ou la renovation.",
|
||||||
|
5: "Demarches a l'echelle du territoire : villes en transition, PLU alternatifs, coalitions territoriales.",
|
||||||
|
6: "Recherche-action et production de contre-savoirs (Forensic Architecture, Rural Studio, PEROU, Centrala). Badge transversal aux familles.",
|
||||||
|
}
|
||||||
|
|
||||||
|
let simulation: any = null
|
||||||
|
let d3NodeSelection: any = null
|
||||||
|
let d3LinkSelection: any = null
|
||||||
|
|
||||||
|
async function initGraph() {
|
||||||
|
if (!svgRef.value || !props.data) return
|
||||||
|
|
||||||
|
const d3 = await import('d3')
|
||||||
|
|
||||||
|
const svgEl = svgRef.value
|
||||||
|
const width = svgEl.clientWidth || 800
|
||||||
|
const height = svgEl.clientHeight || 600
|
||||||
|
|
||||||
|
// Nettoyer
|
||||||
|
d3.select(svgEl).selectAll('*').remove()
|
||||||
|
closePopover()
|
||||||
|
|
||||||
|
const svg = d3.select(svgEl)
|
||||||
|
.attr('viewBox', `0 0 ${width} ${height}`)
|
||||||
|
|
||||||
|
// Click sur le SVG vide -> fermer popover
|
||||||
|
svg.on('click', (event: any) => {
|
||||||
|
if (event.target === svgEl) closePopover()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Groupe principal avec zoom
|
||||||
|
const g = svg.append('g')
|
||||||
|
const zoomBehavior = d3.zoom<SVGElement, unknown>()
|
||||||
|
.scaleExtent([0.2, 4])
|
||||||
|
.on('zoom', (event) => {
|
||||||
|
g.attr('transform', event.transform)
|
||||||
|
closePopover()
|
||||||
|
})
|
||||||
|
|
||||||
|
svg.call(zoomBehavior as any)
|
||||||
|
|
||||||
|
const { allNodes, links } = buildNodesLinks(width, height)
|
||||||
|
|
||||||
|
// Simulation force-directed
|
||||||
|
if (simulation) simulation.stop()
|
||||||
|
|
||||||
|
// Adapter la charge selon le nombre de noeuds (mode "tout coche" = plus de repulsion)
|
||||||
|
const heavyMode = showPratiques.value && allNodes.length > 150
|
||||||
|
|
||||||
|
simulation = d3.forceSimulation(allNodes)
|
||||||
|
.force('link', d3.forceLink(links).id((d: any) => d.id)
|
||||||
|
.distance((d: any) => {
|
||||||
|
if (d.type === 'practice') return 90
|
||||||
|
return d.type === 'primary' ? 80 : 120
|
||||||
|
})
|
||||||
|
.strength((d: any) => d.strength ?? 0.5))
|
||||||
|
.force('charge', d3.forceManyBody().strength(heavyMode ? -80 : -120))
|
||||||
|
.force('center', d3.forceCenter(width / 2, height / 2))
|
||||||
|
.force('collision', d3.forceCollide().radius((d: any) => d.r + 4))
|
||||||
|
|
||||||
|
// Rendu liens
|
||||||
|
d3LinkSelection = g.append('g').selectAll('line')
|
||||||
|
.data(links)
|
||||||
|
.join('line')
|
||||||
|
.attr('stroke', (d: any) => {
|
||||||
|
if (d.type === 'practice') return 'rgba(150,150,150,0.25)'
|
||||||
|
return d.type === 'primary' ? 'rgba(150,150,150,0.45)' : 'rgba(150,150,150,0.35)'
|
||||||
|
})
|
||||||
|
.attr('stroke-width', 1.5)
|
||||||
|
.attr('stroke-dasharray', null)
|
||||||
|
|
||||||
|
// Rendu noeuds (groupes g)
|
||||||
|
d3NodeSelection = g.append('g').selectAll('g')
|
||||||
|
.data(allNodes)
|
||||||
|
.join('g')
|
||||||
|
.style('cursor', (d: any) => {
|
||||||
|
if (d.type === 'structure') return 'pointer'
|
||||||
|
if (d.type === 'family') return 'pointer'
|
||||||
|
if (d.type === 'hashtag') return 'pointer'
|
||||||
|
return 'default'
|
||||||
|
})
|
||||||
|
.call(
|
||||||
|
d3.drag<any, any>()
|
||||||
|
.on('start', (event: any, d: any) => {
|
||||||
|
if (!event.active) simulation.alphaTarget(0.3).restart()
|
||||||
|
d.fx = d.x
|
||||||
|
d.fy = d.y
|
||||||
|
closePopover()
|
||||||
|
})
|
||||||
|
.on('drag', (event: any, d: any) => {
|
||||||
|
d.fx = event.x
|
||||||
|
d.fy = event.y
|
||||||
|
})
|
||||||
|
.on('end', (event: any, d: any) => {
|
||||||
|
if (!event.active) simulation.alphaTarget(0)
|
||||||
|
if (d.type !== 'family') {
|
||||||
|
d.fx = null
|
||||||
|
d.fy = null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.on('click', (event: any, d: any) => {
|
||||||
|
event.stopPropagation()
|
||||||
|
if (d.type === 'structure') {
|
||||||
|
emit('select-structure', d.id)
|
||||||
|
} else if (d.type === 'family') {
|
||||||
|
openFamillePopover(d, event, svgEl)
|
||||||
|
} else if (d.type === 'hashtag') {
|
||||||
|
openHashtagPopover(d, event, svgEl)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Cercles
|
||||||
|
d3NodeSelection.append('circle')
|
||||||
|
.attr('r', (d: any) => d.r)
|
||||||
|
.attr('fill', (d: any) => {
|
||||||
|
if (d.type === 'family') return d.color
|
||||||
|
if (d.type === 'hashtag') return d.fill
|
||||||
|
return d.color + 'cc'
|
||||||
|
})
|
||||||
|
.attr('stroke', (d: any) => {
|
||||||
|
if (d.type === 'family') return 'white'
|
||||||
|
if (d.type === 'hashtag') return d.stroke
|
||||||
|
return d.color
|
||||||
|
})
|
||||||
|
.attr('stroke-width', (d: any) => {
|
||||||
|
if (d.type === 'family') return 3
|
||||||
|
if (d.type === 'hashtag') return 2
|
||||||
|
return 1.5
|
||||||
|
})
|
||||||
|
|
||||||
|
// Labels familles
|
||||||
|
d3NodeSelection.filter((d: any) => d.type === 'family')
|
||||||
|
.append('text')
|
||||||
|
.text((d: any) => d.label)
|
||||||
|
.attr('text-anchor', 'middle')
|
||||||
|
.attr('dy', '0.35em')
|
||||||
|
.attr('font-size', '11px')
|
||||||
|
.attr('font-weight', '700')
|
||||||
|
.attr('fill', 'white')
|
||||||
|
.style('pointer-events', 'none')
|
||||||
|
|
||||||
|
// Labels hashtags : texte noir sur fond clair, tronque a 12 caracteres
|
||||||
|
d3NodeSelection.filter((d: any) => d.type === 'hashtag')
|
||||||
|
.append('text')
|
||||||
|
.text((d: any) => {
|
||||||
|
const raw = d.label as string
|
||||||
|
return raw.length > 12 ? raw.slice(0, 12) + '...' : raw
|
||||||
|
})
|
||||||
|
.attr('text-anchor', 'middle')
|
||||||
|
.attr('dy', '0.35em')
|
||||||
|
.attr('font-size', '9px')
|
||||||
|
.attr('font-weight', '600')
|
||||||
|
.attr('fill', '#2a2a2a')
|
||||||
|
.style('pointer-events', 'none')
|
||||||
|
|
||||||
|
// Labels structures : nom au-dessus du cercle, halo pour lisibilite
|
||||||
|
d3NodeSelection.filter((d: any) => d.type === 'structure')
|
||||||
|
.append('text')
|
||||||
|
.attr('class', 'graph-struct-label')
|
||||||
|
.text((d: any) => {
|
||||||
|
const raw = d.label as string
|
||||||
|
return raw.length > 22 ? raw.slice(0, 20) + '…' : raw
|
||||||
|
})
|
||||||
|
.attr('text-anchor', 'middle')
|
||||||
|
.attr('dy', (d: any) => -(d.r + 5))
|
||||||
|
.attr('font-size', '9.5px')
|
||||||
|
.attr('font-weight', '500')
|
||||||
|
.style('pointer-events', 'none')
|
||||||
|
|
||||||
|
// Tooltip hover pour structures
|
||||||
|
d3NodeSelection.filter((d: any) => d.type === 'structure')
|
||||||
|
.on('mouseenter', (_event: any, d: any) => {
|
||||||
|
if (!tooltipRef.value) return
|
||||||
|
tooltipRef.value.style.opacity = '1'
|
||||||
|
tooltipRef.value.innerHTML = `<strong>${d.label}</strong><br><span style="opacity:0.6;font-size:0.7rem;">${FAMILLE_LABELS[d.famille] ?? ''}</span>`
|
||||||
|
})
|
||||||
|
.on('mousemove', (event: any) => {
|
||||||
|
if (!tooltipRef.value || !svgEl) return
|
||||||
|
const rect = (svgEl as HTMLElement).getBoundingClientRect()
|
||||||
|
tooltipRef.value.style.left = (event.clientX - rect.left + 12) + 'px'
|
||||||
|
tooltipRef.value.style.top = (event.clientY - rect.top - 10) + 'px'
|
||||||
|
})
|
||||||
|
.on('mouseleave', () => {
|
||||||
|
if (tooltipRef.value) tooltipRef.value.style.opacity = '0'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Tick - mise a jour positions
|
||||||
|
simulation.on('tick', () => {
|
||||||
|
d3LinkSelection
|
||||||
|
.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)
|
||||||
|
|
||||||
|
d3NodeSelection.attr('transform', (d: any) => `translate(${d.x},${d.y})`)
|
||||||
|
|
||||||
|
// Surlignage selon hashtags actifs
|
||||||
|
applyHashtagFilter()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildNodesLinks(width: number, height: number) {
|
||||||
|
const allNodes: any[] = []
|
||||||
|
const links: any[] = []
|
||||||
|
|
||||||
|
if (!props.data) return { allNodes, links }
|
||||||
|
|
||||||
|
const tagFamilleMap = tagToFamille.value
|
||||||
|
|
||||||
|
// Noeuds structures (toujours presents)
|
||||||
|
const structureNodes = props.data.structures.map(s => ({
|
||||||
|
id: s.id,
|
||||||
|
type: 'structure',
|
||||||
|
label: s.nom,
|
||||||
|
famille: s.famille_principale,
|
||||||
|
familles_secondaires: s.familles_secondaires ?? [],
|
||||||
|
hashtags: s.hashtags,
|
||||||
|
color: FAMILLE_COLORS[s.famille_principale] ?? '#888',
|
||||||
|
r: 8,
|
||||||
|
}))
|
||||||
|
allNodes.push(...structureNodes)
|
||||||
|
|
||||||
|
// Layer Familles : 6 noeuds famille fixes en etoile + liens primaires/secondaires
|
||||||
|
if (showFamilles.value) {
|
||||||
|
const familyNodes = [1, 2, 3, 4, 5, 6].map(id => ({
|
||||||
|
id: `family-${id}`,
|
||||||
|
type: 'family',
|
||||||
|
familleId: id,
|
||||||
|
label: FAMILLE_LABELS[id],
|
||||||
|
color: FAMILLE_COLORS[id],
|
||||||
|
r: 32,
|
||||||
|
x: width / 2 + Math.cos((id - 1) * Math.PI * 2 / 6) * 180,
|
||||||
|
y: height / 2 + Math.sin((id - 1) * Math.PI * 2 / 6) * 180,
|
||||||
|
fx: width / 2 + Math.cos((id - 1) * Math.PI * 2 / 6) * 180,
|
||||||
|
fy: height / 2 + Math.sin((id - 1) * Math.PI * 2 / 6) * 180,
|
||||||
|
}))
|
||||||
|
allNodes.push(...familyNodes)
|
||||||
|
|
||||||
|
structureNodes.forEach(s => {
|
||||||
|
links.push({
|
||||||
|
source: s.id,
|
||||||
|
target: `family-${s.famille}`,
|
||||||
|
type: 'primary',
|
||||||
|
strength: 0.55,
|
||||||
|
})
|
||||||
|
;(s.familles_secondaires as number[]).forEach((f: number) => {
|
||||||
|
links.push({
|
||||||
|
source: s.id,
|
||||||
|
target: `family-${f}`,
|
||||||
|
type: 'secondary',
|
||||||
|
strength: 0.45,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Layer Pratiques : noeuds hashtag + liens structure -> hashtag
|
||||||
|
if (showPratiques.value) {
|
||||||
|
const uniqueTags = new Set<string>()
|
||||||
|
props.data.structures.forEach(s => s.hashtags.forEach(t => uniqueTags.add(t)))
|
||||||
|
const tagsArr = Array.from(uniqueTags).sort()
|
||||||
|
|
||||||
|
// Si seul layer Pratiques actif : disposition radiale comme reference
|
||||||
|
// Si superpose avec Familles : laisser la simulation placer
|
||||||
|
const radius = Math.min(width, height) * 0.32
|
||||||
|
const hashtagNodes = tagsArr.map((tag, i) => {
|
||||||
|
const famId = tagFamilleMap[tag]
|
||||||
|
const strokeColor = famId != null ? FAMILLE_COLORS[famId] : '#888'
|
||||||
|
const node: any = {
|
||||||
|
id: `hashtag-${tag}`,
|
||||||
|
type: 'hashtag',
|
||||||
|
label: tag.startsWith('#') ? tag.slice(1) : tag,
|
||||||
|
tag,
|
||||||
|
fill: 'var(--nav-bg-alt)',
|
||||||
|
stroke: strokeColor,
|
||||||
|
color: strokeColor,
|
||||||
|
r: 22,
|
||||||
|
}
|
||||||
|
if (!showFamilles.value) {
|
||||||
|
const angle = (i / tagsArr.length) * Math.PI * 2
|
||||||
|
node.x = width / 2 + Math.cos(angle) * radius
|
||||||
|
node.y = height / 2 + Math.sin(angle) * radius
|
||||||
|
}
|
||||||
|
return node
|
||||||
|
})
|
||||||
|
allNodes.push(...hashtagNodes)
|
||||||
|
|
||||||
|
structureNodes.forEach(s => {
|
||||||
|
s.hashtags.forEach(tag => {
|
||||||
|
if (uniqueTags.has(tag)) {
|
||||||
|
links.push({
|
||||||
|
source: s.id,
|
||||||
|
target: `hashtag-${tag}`,
|
||||||
|
type: 'practice',
|
||||||
|
strength: 0.3,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return { allNodes, links }
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampPopoverPosition(rect: DOMRect, evtX: number, evtY: number, w = 280, h = 180) {
|
||||||
|
const margin = 12
|
||||||
|
let x = evtX - rect.left + 14
|
||||||
|
let y = evtY - rect.top + 10
|
||||||
|
if (x + w > rect.width - margin) {
|
||||||
|
x = Math.max(margin, rect.width - w - margin)
|
||||||
|
}
|
||||||
|
if (y + h > rect.height - margin) {
|
||||||
|
y = Math.max(margin, rect.height - h - margin)
|
||||||
|
}
|
||||||
|
return { x, y }
|
||||||
|
}
|
||||||
|
|
||||||
|
function structuresForFamille(famId: number): { id: string; nom: string }[] {
|
||||||
|
if (!props.data) return []
|
||||||
|
return props.data.structures
|
||||||
|
.filter(s =>
|
||||||
|
s.famille_principale === famId
|
||||||
|
|| (s.familles_secondaires ?? []).includes(famId)
|
||||||
|
)
|
||||||
|
.sort((a, b) => a.nom.localeCompare(b.nom, 'fr'))
|
||||||
|
.map(s => ({ id: s.id, nom: s.nom }))
|
||||||
|
}
|
||||||
|
|
||||||
|
function openFamillePopover(d: any, event: any, svgEl: SVGElement) {
|
||||||
|
const rect = (svgEl as HTMLElement).getBoundingClientRect()
|
||||||
|
const famId = d.familleId as number
|
||||||
|
const desc = FAMILLE_DESCRIPTIONS[famId] ?? ''
|
||||||
|
const structures = structuresForFamille(famId)
|
||||||
|
const { x, y } = clampPopoverPosition(rect, event.clientX, event.clientY, 280, 280)
|
||||||
|
popover.value = {
|
||||||
|
open: true,
|
||||||
|
kind: 'famille',
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
title: FAMILLE_LABELS[famId] ?? '',
|
||||||
|
body: desc,
|
||||||
|
color: FAMILLE_COLORS[famId] ?? '#000',
|
||||||
|
structures,
|
||||||
|
familleId: famId,
|
||||||
|
famillesCount: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openHashtagPopover(d: any, event: any, svgEl: SVGElement) {
|
||||||
|
const rect = (svgEl as HTMLElement).getBoundingClientRect()
|
||||||
|
const tag = d.tag as string
|
||||||
|
const structures = structuresForHashtag(tag)
|
||||||
|
const famId = tagToFamille.value[tag]
|
||||||
|
const color = famId != null ? FAMILLE_COLORS[famId] : '#444'
|
||||||
|
// Compter les familles distinctes parmi les porteuses (famille_principale)
|
||||||
|
const famSet = new Set<number>()
|
||||||
|
if (props.data) {
|
||||||
|
props.data.structures
|
||||||
|
.filter(s => s.hashtags.includes(tag))
|
||||||
|
.forEach(s => famSet.add(s.famille_principale))
|
||||||
|
}
|
||||||
|
const { x, y } = clampPopoverPosition(rect, event.clientX, event.clientY, 280, 220)
|
||||||
|
popover.value = {
|
||||||
|
open: true,
|
||||||
|
kind: 'hashtag',
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
title: tag.startsWith('#') ? tag : '#' + tag,
|
||||||
|
body: '',
|
||||||
|
color,
|
||||||
|
structures,
|
||||||
|
familleId: null,
|
||||||
|
famillesCount: famSet.size,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyHashtagFilter() {
|
||||||
|
if (!d3NodeSelection || !d3LinkSelection) return
|
||||||
|
if (filteredStructureIds.value) {
|
||||||
|
const ids = filteredStructureIds.value
|
||||||
|
d3NodeSelection.filter((d: any) => d.type === 'structure').select('circle')
|
||||||
|
.attr('opacity', (d: any) => ids.has(d.id) ? 1 : 0.1)
|
||||||
|
d3LinkSelection.attr('opacity', (d: any) => {
|
||||||
|
const srcId = typeof d.source === 'object' ? d.source.id : d.source
|
||||||
|
const tgtId = typeof d.target === 'object' ? d.target.id : d.target
|
||||||
|
return ids.has(srcId) || ids.has(tgtId) ? 1 : 0.05
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
d3NodeSelection.select('circle').attr('opacity', 1)
|
||||||
|
d3LinkSelection.attr('opacity', 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Declencher quand l'onglet devient visible
|
||||||
|
watch(() => props.active, (val) => {
|
||||||
|
if (val && import.meta.client && props.data) {
|
||||||
|
requestAnimationFrame(() => requestAnimationFrame(() => initGraph()))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Relancer si les donnees arrivent apres l'activation
|
||||||
|
watch(() => props.data, (val) => {
|
||||||
|
if (val && props.active && import.meta.client) {
|
||||||
|
requestAnimationFrame(() => requestAnimationFrame(() => initGraph()))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Re-appliquer le filtre visuel sans rebuild complet
|
||||||
|
watch(activeHashtags, () => {
|
||||||
|
applyHashtagFilter()
|
||||||
|
if (simulation) simulation.alpha(0.01).restart()
|
||||||
|
}, { deep: true })
|
||||||
|
|
||||||
|
// Watchers layers : rebuild simulation
|
||||||
|
watch([showFamilles, showPratiques], () => {
|
||||||
|
closePopover()
|
||||||
|
if (import.meta.client && props.data && props.active) {
|
||||||
|
requestAnimationFrame(() => requestAnimationFrame(() => initGraph()))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Toggle sidebar : largeur SVG change -> reinit graphe apres transition CSS
|
||||||
|
watch(sidebarOpen, () => {
|
||||||
|
if (!import.meta.client || !props.active || !props.data) return
|
||||||
|
setTimeout(() => {
|
||||||
|
requestAnimationFrame(() => requestAnimationFrame(() => initGraph()))
|
||||||
|
}, 220)
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
if (import.meta.client && props.data && props.active) {
|
||||||
|
await nextTick()
|
||||||
|
initGraph()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (simulation) simulation.stop()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* 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;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
97
components/HashtagFilter.vue
Normal file
97
components/HashtagFilter.vue
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
<template>
|
||||||
|
<div class="hashtag-filter" style="padding: 8px 12px; background: var(--nav-surface);">
|
||||||
|
<!-- Filtres famille -->
|
||||||
|
<div style="margin-bottom: 6px;">
|
||||||
|
<span class="filter-label">FAMILLES</span>
|
||||||
|
<div class="chips-row">
|
||||||
|
<span
|
||||||
|
v-for="fam in FAMILLES"
|
||||||
|
:key="fam.id"
|
||||||
|
class="chip"
|
||||||
|
:style="selectedFamille === fam.id
|
||||||
|
? `background: ${fam.color}; color: white; font-weight: 600; border: 2px solid ${fam.color};`
|
||||||
|
: `background: var(--nav-bg-alt); color: ${fam.color}; border: 2px solid ${fam.color}; font-weight: 600;`"
|
||||||
|
@click="toggleFamille(fam.id)"
|
||||||
|
>{{ fam.shortLabel }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filtres hashtags avec toggle -->
|
||||||
|
<div>
|
||||||
|
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 4px;">
|
||||||
|
<span class="filter-label">HASHTAGS</span>
|
||||||
|
<button
|
||||||
|
@click="hashtagsVisible = !hashtagsVisible"
|
||||||
|
style="font-size: 0.7rem; color: var(--nav-text-muted); background: none; border: none; cursor: pointer; text-decoration: underline; padding: 0;"
|
||||||
|
>{{ hashtagsVisible ? 'Replier' : 'Afficher (' + props.allHashtags.length + ')' }}</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="hashtagsVisible" class="chips-row">
|
||||||
|
<span
|
||||||
|
v-for="tag in props.allHashtags"
|
||||||
|
:key="tag"
|
||||||
|
class="chip chip-small"
|
||||||
|
:style="selectedHashtags.includes(tag)
|
||||||
|
? 'background: var(--nav-primary); color: var(--nav-text-on-primary); font-weight: 600;'
|
||||||
|
: 'background: var(--nav-bg-alt); color: var(--nav-text-muted);'"
|
||||||
|
@click="toggleHashtag(tag)"
|
||||||
|
>{{ tag }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const FAMILLES = [
|
||||||
|
{ id: 1, shortLabel: 'Réemploi', color: '#a85d3e' },
|
||||||
|
{ id: 2, shortLabel: 'Frugalité', color: '#c4a472' },
|
||||||
|
{ id: 3, shortLabel: 'Social', color: '#d4a017' },
|
||||||
|
{ id: 4, shortLabel: 'Collectifs', color: '#5a7a4a' },
|
||||||
|
{ id: 5, shortLabel: 'Urbanisme', color: '#3d6a8c' },
|
||||||
|
{ id: 6, shortLabel: 'Recherche', color: '#6b3fa0' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
allHashtags: string[]
|
||||||
|
selectedHashtags: string[]
|
||||||
|
selectedFamille: number | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:selectedHashtags': [v: string[]]
|
||||||
|
'update:selectedFamille': [v: number | null]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const hashtagsVisible = ref(false)
|
||||||
|
|
||||||
|
function toggleFamille(id: number) {
|
||||||
|
emit('update:selectedFamille', props.selectedFamille === id ? null : id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleHashtag(tag: string) {
|
||||||
|
const current = props.selectedHashtags
|
||||||
|
emit('update:selectedHashtags',
|
||||||
|
current.includes(tag) ? current.filter(t => t !== tag) : [...current, tag]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.filter-label {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: var(--nav-text-muted);
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.chips-row { display: flex; flex-wrap: wrap; gap: 4px; margin-bottom: 4px; }
|
||||||
|
.chip {
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
transition: all 0.15s;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.chip-small { font-size: 0.7rem; padding: 2px 8px; }
|
||||||
|
</style>
|
||||||
76
components/IntentionBanner.vue
Normal file
76
components/IntentionBanner.vue
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<Transition name="fade">
|
||||||
|
<div
|
||||||
|
v-if="visible"
|
||||||
|
class="intention-overlay"
|
||||||
|
style="
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 2000;
|
||||||
|
background: rgba(20, 18, 14, 0.85);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 24px;
|
||||||
|
"
|
||||||
|
@click.self="dismiss"
|
||||||
|
>
|
||||||
|
<div style="
|
||||||
|
max-width: 540px;
|
||||||
|
width: 100%;
|
||||||
|
background: #faf8f5;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 32px;
|
||||||
|
color: #2c2416;
|
||||||
|
">
|
||||||
|
<p style="font-size: 1rem; line-height: 1.7; margin: 0 0 12px 0;">
|
||||||
|
Cette carte recense les réseaux, collectifs, agences et projets où des
|
||||||
|
pensées écologiques deviennent des pratiques d'architecture et d'habiter.
|
||||||
|
</p>
|
||||||
|
<p style="font-size: 0.875rem; line-height: 1.6; opacity: 0.75; margin: 0 0 24px 0;">
|
||||||
|
Elle ne prétend pas à l'exhaustivité. Elle est un geste politique :
|
||||||
|
rendre visible ce qui se transforme, comment, par qui, où.
|
||||||
|
5 familles et des hashtags vous permettent d'explorer.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
@click="dismiss"
|
||||||
|
style="
|
||||||
|
background: #2c2416;
|
||||||
|
color: #faf8f5;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 10px 24px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
width: 100%;
|
||||||
|
"
|
||||||
|
>Explorer la carte</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const visible = ref(false)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (typeof localStorage !== 'undefined' && !localStorage.getItem('aep_intention_seen')) {
|
||||||
|
visible.value = true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function dismiss() {
|
||||||
|
visible.value = false
|
||||||
|
if (typeof localStorage !== 'undefined') {
|
||||||
|
localStorage.setItem('aep_intention_seen', '1')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.fade-enter-active, .fade-leave-active { transition: opacity 0.3s; }
|
||||||
|
.fade-enter-from, .fade-leave-to { opacity: 0; }
|
||||||
|
</style>
|
||||||
181
components/MissionPopup.vue
Normal file
181
components/MissionPopup.vue
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<Transition name="backdrop">
|
||||||
|
<div
|
||||||
|
v-if="modelValue"
|
||||||
|
class="fixed inset-0 z-[1500]"
|
||||||
|
style="background: rgba(26,34,56,0.55);"
|
||||||
|
@click="close"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</Transition>
|
||||||
|
|
||||||
|
<Transition name="modal">
|
||||||
|
<div
|
||||||
|
v-if="modelValue"
|
||||||
|
class="mission-modal"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="mission-title"
|
||||||
|
@keydown.esc="close"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="mission-close"
|
||||||
|
type="button"
|
||||||
|
@click="close"
|
||||||
|
aria-label="Fermer"
|
||||||
|
>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" aria-hidden="true">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="mission-body">
|
||||||
|
<h2 id="mission-title" class="mission-title">{{ title }}</h2>
|
||||||
|
<slot>
|
||||||
|
<p class="mission-text">
|
||||||
|
L'architecture est l'une des professions les plus complexes qui soit ; elle croise droit, technique, esthétique, économie, social, écologie — tout à la fois, tout simultanément, souvent sans filet. Paradoxalement, c'est aussi l'une des moins structurées sur le plan de l'entraide : peu de transmission horizontale, beaucoup d'isolement, une culture du chacun-pour-soi héritée d'une formation qui prépare à la compétition plus qu'à la coopération. On sort de l'école seul·e. On s'installe seul·e. On réinvente ce que d'autres ont déjà traversé.
|
||||||
|
</p>
|
||||||
|
<p class="mission-text">
|
||||||
|
Cette carte est née de cette frustration — et de cette conviction : les ressources existent, les gens qui ont réussi à sortir la tête de l'eau aussi. L'enjeu, c'est de les documenter, de les rendre accessibles, de les ajuster en temps réel grâce aux retours de la communauté. Pas un catalogue figé ; un commun vivant, au service de ceux et celles qui cherchent à faire évoluer leur pratique vers quelque chose de plus épanouissant, mieux rémunéré, au service de la société — et qui prend soin de la santé, la nôtre et celle des gens pour qui nous construisons.
|
||||||
|
</p>
|
||||||
|
</slot>
|
||||||
|
|
||||||
|
<div class="mission-cta-wrap">
|
||||||
|
<button class="btn-explorer" type="button" @click="close">{{ ctaLabel }}</button>
|
||||||
|
<NuxtLink to="/manifeste" class="link-manifeste" @click="close">Lire le manifeste →</NuxtLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
modelValue: boolean
|
||||||
|
title?: string
|
||||||
|
ctaLabel?: string
|
||||||
|
storageKey?: string
|
||||||
|
}>(), {
|
||||||
|
title: "L'écosystème d'entraide architecte",
|
||||||
|
ctaLabel: 'Explorer la carte',
|
||||||
|
storageKey: 'aep_mission_seen',
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
emit('update:modelValue', false)
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
try { localStorage.setItem(props.storageKey, '1') } catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.mission-modal {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 1501;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
width: min(560px, 92vw);
|
||||||
|
max-height: calc(100dvh - 80px);
|
||||||
|
background: var(--nav-bg);
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow: 0 16px 64px rgba(26,34,56,0.28);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mission-close {
|
||||||
|
position: absolute;
|
||||||
|
top: 12px;
|
||||||
|
right: 12px;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: none;
|
||||||
|
background: var(--nav-bg-alt);
|
||||||
|
color: var(--nav-text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: background 0.15s;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
.mission-close:hover { background: var(--nav-surface); }
|
||||||
|
|
||||||
|
.mission-body {
|
||||||
|
padding: 1.75rem 1.5rem 1.5rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mission-title {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--nav-text);
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
line-height: 1.25;
|
||||||
|
padding-right: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mission-text,
|
||||||
|
:slotted(.mission-text) {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
line-height: 1.65;
|
||||||
|
color: var(--nav-text);
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
}
|
||||||
|
:slotted(.mission-text strong) { font-weight: 700; }
|
||||||
|
:slotted(.mission-text a) { color: var(--nav-primary-solid); text-decoration: underline; }
|
||||||
|
|
||||||
|
.mission-cta-wrap {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-explorer {
|
||||||
|
padding: 0.65rem 1.25rem;
|
||||||
|
background: var(--nav-primary);
|
||||||
|
color: var(--nav-text-on-primary);
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
.btn-explorer:hover { opacity: 0.88; }
|
||||||
|
|
||||||
|
.link-manifeste {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--nav-primary-solid);
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 2px;
|
||||||
|
}
|
||||||
|
.link-manifeste:hover { opacity: 0.75; }
|
||||||
|
|
||||||
|
.backdrop-enter-active, .backdrop-leave-active { transition: opacity 0.2s ease; }
|
||||||
|
.backdrop-enter-from, .backdrop-leave-to { opacity: 0; }
|
||||||
|
|
||||||
|
.modal-enter-active, .modal-leave-active { transition: opacity 0.2s ease, transform 0.2s ease; }
|
||||||
|
.modal-enter-from, .modal-leave-to { opacity: 0; transform: translate(-50%, -48%); }
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.mission-body { padding: 1.5rem 1.1rem 1.25rem; }
|
||||||
|
.mission-title { font-size: 1.1rem; }
|
||||||
|
.mission-text { font-size: 0.9rem; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.modal-enter-active, .modal-leave-active { transition: none; }
|
||||||
|
.backdrop-enter-active, .backdrop-leave-active { transition: none; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -221,7 +221,12 @@ function updateTileTheme(dark: boolean) {
|
|||||||
let themeObserver: MutationObserver | null = null
|
let themeObserver: MutationObserver | null = null
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
initMap()
|
// Double rAF : laisser le browser calculer la hauteur du conteneur avant Leaflet
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
initMap()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
// Observer les changements de classe dark sur <html>
|
// Observer les changements de classe dark sur <html>
|
||||||
themeObserver = new MutationObserver(() => {
|
themeObserver = new MutationObserver(() => {
|
||||||
|
|||||||
243
components/NavMapV2.vue
Normal file
243
components/NavMapV2.vue
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
<template>
|
||||||
|
<div class="relative w-full h-full">
|
||||||
|
<div ref="mapContainer" class="w-full h-full rounded-none" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { Map, Marker, DivIcon } from 'leaflet'
|
||||||
|
import type { StructureV2 } from '~/types/structure-v2'
|
||||||
|
|
||||||
|
// Couleurs par famille (synchronisées avec v2-bifurcation.css)
|
||||||
|
const FAMILLE_COLORS: Record<number, string> = {
|
||||||
|
1: '#a85d3e',
|
||||||
|
2: '#c4a472',
|
||||||
|
3: '#d4a017',
|
||||||
|
4: '#5a7a4a',
|
||||||
|
5: '#3d6a8c',
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
structures: StructureV2[]
|
||||||
|
selectedId?: string | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'select-structure': [id: string]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const mapContainer = ref<HTMLElement | null>(null)
|
||||||
|
let mapInstance: Map | null = null
|
||||||
|
let clusterGroup: any = null
|
||||||
|
const markers = new Map<string, Marker>()
|
||||||
|
let tileLayerInstance: any = null
|
||||||
|
|
||||||
|
function getFamilleColor(famille: number): string {
|
||||||
|
return FAMILLE_COLORS[famille] ?? '#888888'
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPinIcon(famille: number, isSelected = false): DivIcon {
|
||||||
|
const L = (window as any).L
|
||||||
|
const bg = getFamilleColor(famille)
|
||||||
|
const size = isSelected ? 20 : 14
|
||||||
|
const border = isSelected ? 'white' : 'rgba(255,255,255,0.7)'
|
||||||
|
const shadow = isSelected ? `0 0 0 4px ${bg}55` : 'none'
|
||||||
|
|
||||||
|
return L.divIcon({
|
||||||
|
className: '',
|
||||||
|
html: `<div style="
|
||||||
|
width: ${size}px;
|
||||||
|
height: ${size}px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: ${bg};
|
||||||
|
border: 2px solid ${border};
|
||||||
|
box-shadow: ${shadow};
|
||||||
|
transition: all 0.2s;
|
||||||
|
"></div>`,
|
||||||
|
iconSize: [size, size],
|
||||||
|
iconAnchor: [size / 2, size / 2],
|
||||||
|
popupAnchor: [0, -(size / 2 + 4)],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initMap() {
|
||||||
|
if (!mapContainer.value) return
|
||||||
|
|
||||||
|
const Lmod = await import('leaflet')
|
||||||
|
const L: any = (Lmod as any).default || Lmod
|
||||||
|
await import('leaflet/dist/leaflet.css')
|
||||||
|
// @ts-ignore
|
||||||
|
await import('leaflet.markercluster/dist/MarkerCluster.css')
|
||||||
|
// @ts-ignore
|
||||||
|
await import('leaflet.markercluster/dist/MarkerCluster.Default.css')
|
||||||
|
|
||||||
|
// Installer L globalement AVANT le plugin
|
||||||
|
;(window as any).L = L
|
||||||
|
// @ts-ignore
|
||||||
|
await import('leaflet.markercluster')
|
||||||
|
const MarkerClusterGroup = L.MarkerClusterGroup
|
||||||
|
|
||||||
|
mapInstance = L.map(mapContainer.value, {
|
||||||
|
center: [46.6, 2.3],
|
||||||
|
zoom: 5,
|
||||||
|
zoomControl: true,
|
||||||
|
attributionControl: true,
|
||||||
|
minZoom: 2,
|
||||||
|
maxZoom: 18,
|
||||||
|
})
|
||||||
|
|
||||||
|
const isDark = typeof document !== 'undefined' && document.documentElement.classList.contains('dark')
|
||||||
|
const tileUrl = isDark
|
||||||
|
? 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'
|
||||||
|
: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png'
|
||||||
|
|
||||||
|
tileLayerInstance = L.tileLayer(tileUrl, {
|
||||||
|
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors © <a href="https://carto.com/attributions">CARTO</a>',
|
||||||
|
maxZoom: 19,
|
||||||
|
})
|
||||||
|
tileLayerInstance.addTo(mapInstance!)
|
||||||
|
|
||||||
|
clusterGroup = new MarkerClusterGroup({
|
||||||
|
disableClusteringAtZoom: 14,
|
||||||
|
maxClusterRadius: 50,
|
||||||
|
showCoverageOnHover: false,
|
||||||
|
iconCreateFunction: (cluster: any) => {
|
||||||
|
const count = cluster.getChildCount()
|
||||||
|
return L.divIcon({
|
||||||
|
html: `<div style="
|
||||||
|
width: 36px; height: 36px; border-radius: 50%;
|
||||||
|
background: var(--nav-primary);
|
||||||
|
color: var(--nav-text-on-primary);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
font-weight: 700; font-size: 13px;
|
||||||
|
border: 2px solid white;
|
||||||
|
font-family: var(--nav-font);
|
||||||
|
">${count}</div>`,
|
||||||
|
className: '',
|
||||||
|
iconSize: [36, 36],
|
||||||
|
iconAnchor: [18, 18],
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
mapInstance.addLayer(clusterGroup)
|
||||||
|
updateMarkers(L)
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateMarkers(L?: any) {
|
||||||
|
if (!mapInstance || !clusterGroup) return
|
||||||
|
const leaflet = L || (window as any).L
|
||||||
|
if (!leaflet) return
|
||||||
|
|
||||||
|
clusterGroup.clearLayers()
|
||||||
|
markers.clear()
|
||||||
|
|
||||||
|
const structuresWithCoords = props.structures.filter(
|
||||||
|
(s) => s.latitude != null && s.longitude != null
|
||||||
|
)
|
||||||
|
|
||||||
|
structuresWithCoords.forEach((structure) => {
|
||||||
|
const isSelected = structure.id === props.selectedId
|
||||||
|
const icon = createPinIcon(structure.famille_principale, isSelected)
|
||||||
|
|
||||||
|
const marker = leaflet.marker([structure.latitude!, structure.longitude!], { icon })
|
||||||
|
|
||||||
|
const hashtagsHtml = structure.hashtags.slice(0, 2)
|
||||||
|
.map(h => `<span style="font-size:10px;color:var(--nav-text-muted);">${h}</span>`)
|
||||||
|
.join(' ')
|
||||||
|
|
||||||
|
marker.bindPopup(`
|
||||||
|
<div style="font-family: var(--nav-font); min-width: 190px; padding: 4px 0;">
|
||||||
|
<div style="font-weight: 700; color: var(--nav-text); margin-bottom: 2px; font-size: 0.9rem;">${structure.nom}</div>
|
||||||
|
<div style="font-size: 11px; color: var(--nav-text-muted); margin-bottom: 4px;">${structure.type_principal} · ${structure.ville}, ${structure.pays}</div>
|
||||||
|
${hashtagsHtml ? `<div style="margin-bottom: 6px;">${hashtagsHtml}</div>` : ''}
|
||||||
|
<div style="font-size: 11px; color: var(--nav-text); line-height: 1.4; margin-bottom: 8px;">${structure.description_courte.slice(0, 100)}…</div>
|
||||||
|
<button onclick="document.dispatchEvent(new CustomEvent('nav-v2-select', {detail:'${structure.id}'}))" style="
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--nav-primary-solid);
|
||||||
|
text-decoration: underline;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
font-family: var(--nav-font);
|
||||||
|
">Voir la fiche →</button>
|
||||||
|
</div>
|
||||||
|
`, { maxWidth: 260 })
|
||||||
|
|
||||||
|
marker.on('click', () => emit('select-structure', structure.id))
|
||||||
|
|
||||||
|
markers.set(structure.id, marker)
|
||||||
|
clusterGroup.addLayer(marker)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ecouter l'event custom depuis les popups Leaflet
|
||||||
|
function onNavV2Select(e: CustomEvent) {
|
||||||
|
emit('select-structure', e.detail)
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.structures,
|
||||||
|
() => updateMarkers(),
|
||||||
|
{ deep: false }
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.selectedId,
|
||||||
|
(newId, oldId) => {
|
||||||
|
if (!mapInstance) return
|
||||||
|
const leaflet = (window as any).L
|
||||||
|
if (!leaflet) return
|
||||||
|
|
||||||
|
if (oldId != null) {
|
||||||
|
const oldMarker = markers.get(oldId)
|
||||||
|
const oldStructure = props.structures.find(s => s.id === oldId)
|
||||||
|
if (oldMarker && oldStructure) {
|
||||||
|
oldMarker.setIcon(createPinIcon(oldStructure.famille_principale, false))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (newId != null) {
|
||||||
|
const newMarker = markers.get(newId)
|
||||||
|
const newStructure = props.structures.find(s => s.id === newId)
|
||||||
|
if (newMarker && newStructure) {
|
||||||
|
newMarker.setIcon(createPinIcon(newStructure.famille_principale, true))
|
||||||
|
const latLng = newMarker.getLatLng()
|
||||||
|
mapInstance.panTo(latLng, { animate: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function updateTileTheme(dark: boolean) {
|
||||||
|
if (!mapInstance || !tileLayerInstance) return
|
||||||
|
const L = (window as any).L
|
||||||
|
if (!L) return
|
||||||
|
const url = dark
|
||||||
|
? 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'
|
||||||
|
: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png'
|
||||||
|
tileLayerInstance.setUrl(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
let themeObserver: MutationObserver | null = null
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
initMap()
|
||||||
|
document.addEventListener('nav-v2-select', onNavV2Select as EventListener)
|
||||||
|
|
||||||
|
themeObserver = new MutationObserver(() => {
|
||||||
|
const dark = document.documentElement.classList.contains('dark')
|
||||||
|
updateTileTheme(dark)
|
||||||
|
})
|
||||||
|
themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] })
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
document.removeEventListener('nav-v2-select', onNavV2Select as EventListener)
|
||||||
|
themeObserver?.disconnect()
|
||||||
|
if (mapInstance) {
|
||||||
|
mapInstance.remove()
|
||||||
|
mapInstance = null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -125,8 +125,8 @@
|
|||||||
<span
|
<span
|
||||||
v-for="fn in orgFonctions(org)"
|
v-for="fn in orgFonctions(org)"
|
||||||
:key="fn"
|
:key="fn"
|
||||||
class="px-1.5 py-0.5 rounded text-xs"
|
class="px-2 py-0.5 rounded-full text-xs font-medium"
|
||||||
style="background: var(--nav-bg-alt); color: var(--nav-text-muted);"
|
style="background: var(--nav-bg-alt); color: var(--nav-text-muted); border: 1px solid var(--nav-bg-alt); letter-spacing: 0.01em;"
|
||||||
>{{ fn }}</span>
|
>{{ fn }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="org.localisation_ville" class="mt-0.5 text-xs" style="color: var(--nav-text-muted);">
|
<div v-if="org.localisation_ville" class="mt-0.5 text-xs" style="color: var(--nav-text-muted);">
|
||||||
|
|||||||
244
components/PlatformeTaffCard.vue
Normal file
244
components/PlatformeTaffCard.vue
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
<template>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="taff-card"
|
||||||
|
:style="`border-left-color: ${tagConfig.accent};`"
|
||||||
|
@click="$emit('open', plateforme)"
|
||||||
|
>
|
||||||
|
<!-- Ligne 1 : tag + badge AO + lien -->
|
||||||
|
<div class="taff-card-top">
|
||||||
|
<div class="flex items-center gap-2 flex-wrap">
|
||||||
|
<span class="taff-tag" :style="`background: ${tagConfig.bg}; color: ${tagConfig.text};`">
|
||||||
|
{{ tagConfig.emoji }} {{ tagConfig.label }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-if="plateforme.type === 'appel-offre-public'"
|
||||||
|
class="taff-badge-ao"
|
||||||
|
>AO public</span>
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
:href="plateforme.url"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="taff-visit-btn"
|
||||||
|
@click.stop
|
||||||
|
title="Visiter le site"
|
||||||
|
>
|
||||||
|
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" 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>
|
||||||
|
Visiter
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Ligne 2 : nom -->
|
||||||
|
<div class="taff-card-name">{{ plateforme.nom }}</div>
|
||||||
|
|
||||||
|
<!-- Ligne 3 : axes (icône + score, compacts) -->
|
||||||
|
<div class="taff-card-axes">
|
||||||
|
<template v-for="axe in AXES" :key="axe.id">
|
||||||
|
<span
|
||||||
|
v-if="plateforme.scoring[axe.id] !== null"
|
||||||
|
class="taff-axe-chip"
|
||||||
|
:style="`background: ${axeScoreBg(plateforme.scoring[axe.id])}; color: ${axeScoreText(plateforme.scoring[axe.id])};`"
|
||||||
|
:title="axe.label"
|
||||||
|
>{{ axe.icon }} {{ plateforme.scoring[axe.id] }}</span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Ligne 4 : description (3 lignes max, lisible) -->
|
||||||
|
<p class="taff-card-desc">{{ plateforme.description_courte }}</p>
|
||||||
|
|
||||||
|
<!-- Ligne 5 : secteurs + coût -->
|
||||||
|
<div class="taff-card-footer">
|
||||||
|
<div class="flex items-center gap-1.5 flex-wrap">
|
||||||
|
<span
|
||||||
|
v-for="s in plateforme.secteurs_servis.slice(0, 3)"
|
||||||
|
:key="s"
|
||||||
|
class="taff-secteur-chip"
|
||||||
|
>{{ SECTEUR_LABELS[s] ?? s }}</span>
|
||||||
|
<span v-if="plateforme.secteurs_servis.length > 3" class="taff-more">+{{ plateforme.secteurs_servis.length - 3 }}</span>
|
||||||
|
</div>
|
||||||
|
<span class="taff-cout">{{ COUT_LABELS[plateforme.cout_entree] ?? plateforme.cout_entree }}</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { PlateformeTaff } from '~/types/plateforme-taff'
|
||||||
|
|
||||||
|
const props = defineProps<{ plateforme: PlateformeTaff }>()
|
||||||
|
defineEmits<{ open: [p: PlateformeTaff] }>()
|
||||||
|
|
||||||
|
const AXES = [
|
||||||
|
{ id: 'remuneration' as const, icon: '🪙', label: 'Rémunération' },
|
||||||
|
{ id: 'transparence' as const, icon: '🔍', label: 'Transparence' },
|
||||||
|
{ id: 'pratiques' as const, icon: '⚖️', label: 'Pratiques pro' },
|
||||||
|
{ id: 'ecologie' as const, icon: '🌿', label: 'Écologie' },
|
||||||
|
{ id: 'matching' as const, icon: '🎯', label: 'Matching' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const TAG_CONFIG = {
|
||||||
|
'recommande': { emoji: '✅', label: 'Recommandé AEP', accent: '#5a7a4a', bg: 'rgba(90,122,74,0.12)', text: '#3d5534' },
|
||||||
|
'sous-reserve': { emoji: '⚠️', label: 'Sous réserve', accent: '#c4a472', bg: 'rgba(196,164,114,0.15)', text: '#7a5f2a' },
|
||||||
|
'a-eviter': { emoji: '❌', label: 'À éviter', accent: '#a85d3e', bg: 'rgba(168,93,62,0.12)', text: '#7a3322' },
|
||||||
|
}
|
||||||
|
|
||||||
|
const tagConfig = computed(() => TAG_CONFIG[props.plateforme.scoring.tag_global] ?? TAG_CONFIG['sous-reserve'])
|
||||||
|
|
||||||
|
function axeScoreBg(score: string | null) {
|
||||||
|
if (score === '✅') return 'rgba(90,122,74,0.12)'
|
||||||
|
if (score === '⚠️') return 'rgba(196,164,114,0.15)'
|
||||||
|
if (score === '❌') return 'rgba(168,93,62,0.12)'
|
||||||
|
return 'var(--nav-bg-alt)'
|
||||||
|
}
|
||||||
|
function axeScoreText(score: string | null) {
|
||||||
|
if (score === '✅') return '#3d5534'
|
||||||
|
if (score === '⚠️') return '#7a5f2a'
|
||||||
|
if (score === '❌') return '#7a3322'
|
||||||
|
return 'var(--nav-text-muted)'
|
||||||
|
}
|
||||||
|
|
||||||
|
const SECTEUR_LABELS: Record<string, string> = {
|
||||||
|
'renovation': 'Rénovation', 'construction-neuve': 'Neuf', 'urbanisme': 'Urbanisme',
|
||||||
|
'architecture-interieure': 'Archi intérieure', 'paysage': 'Paysage',
|
||||||
|
'mar-conseil': 'MAR/Conseil', 'transversal': 'Transversal',
|
||||||
|
}
|
||||||
|
const COUT_LABELS: Record<string, string> = {
|
||||||
|
'gratuit': 'Gratuit', 'freemium': 'Freemium', 'abonnement': 'Abonnement',
|
||||||
|
'lead-paye': 'Lead payant', 'commission': 'Commission',
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.taff-card {
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--nav-bg-alt);
|
||||||
|
border-left: 4px solid;
|
||||||
|
background: var(--nav-surface);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
transition: box-shadow 0.2s;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.taff-card:hover { box-shadow: 0 4px 16px rgba(26,34,56,0.1); }
|
||||||
|
.taff-card:focus-visible { outline: 2px solid var(--nav-accent); outline-offset: 2px; }
|
||||||
|
|
||||||
|
.taff-card-top {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 1rem 1rem 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.taff-tag {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding: 0.25rem 0.625rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.taff-badge-ao {
|
||||||
|
display: inline-flex;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
background: var(--nav-bg-alt);
|
||||||
|
color: var(--nav-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.taff-visit-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
padding: 0.375rem 0.75rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
background: var(--nav-bg-alt);
|
||||||
|
color: var(--nav-text);
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
.taff-visit-btn:hover { opacity: 0.7; }
|
||||||
|
|
||||||
|
.taff-card-name {
|
||||||
|
padding: 0.25rem 1rem 0.75rem;
|
||||||
|
font-size: 1.0625rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--nav-text);
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.taff-card-axes {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
padding: 0 1rem 0.875rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.taff-axe-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding: 0.25rem 0.625rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.taff-card-desc {
|
||||||
|
padding: 0 1rem 1rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
line-height: 1.65;
|
||||||
|
color: var(--nav-text-muted);
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 3;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.taff-card-footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-top: 1px solid var(--nav-bg-alt);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.taff-secteur-chip {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.2rem 0.5rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
background: var(--nav-bg);
|
||||||
|
color: var(--nav-text-muted);
|
||||||
|
border: 1px solid var(--nav-bg-alt);
|
||||||
|
}
|
||||||
|
|
||||||
|
.taff-more {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--nav-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.taff-cout {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--nav-text-muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
450
components/codev/CodevGraph.vue
Normal file
450
components/codev/CodevGraph.vue
Normal file
@@ -0,0 +1,450 @@
|
|||||||
|
<template>
|
||||||
|
<div ref="container" class="codev-graph-wrap">
|
||||||
|
|
||||||
|
<!-- Placeholder si aucune fiche -->
|
||||||
|
<div v-if="fiches.length === 0" class="empty-state">
|
||||||
|
<p class="empty-msg">Encore personne. Sois la premiere fiche !</p>
|
||||||
|
<NuxtLink to="/codev/fiche" class="empty-link">Creer ma fiche →</NuxtLink>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- SVG D3 -->
|
||||||
|
<svg v-else ref="svgEl" class="codev-svg">
|
||||||
|
<defs>
|
||||||
|
<marker
|
||||||
|
id="arrow-solution"
|
||||||
|
viewBox="0 0 10 10"
|
||||||
|
refX="18"
|
||||||
|
refY="5"
|
||||||
|
markerWidth="6"
|
||||||
|
markerHeight="6"
|
||||||
|
orient="auto-start-reverse"
|
||||||
|
>
|
||||||
|
<path d="M 0 0 L 10 5 L 0 10 z" fill="#22c55e" />
|
||||||
|
</marker>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import * as d3 from 'd3'
|
||||||
|
import type { CodevFiche, CodevMatch } from '~/types/codev'
|
||||||
|
|
||||||
|
// ── Props / Emits ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
fiches: CodevFiche[]
|
||||||
|
matches?: CodevMatch[]
|
||||||
|
mode?: 'none' | 'solution' | 'alliance' | 'surprise'
|
||||||
|
showLabels?: boolean
|
||||||
|
}>(), {
|
||||||
|
matches: () => [],
|
||||||
|
mode: 'none',
|
||||||
|
showLabels: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'select-fiche': [id: number]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
// ── Refs ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const container = ref<HTMLDivElement | null>(null)
|
||||||
|
const svgEl = ref<SVGSVGElement | null>(null)
|
||||||
|
const width = ref(800)
|
||||||
|
const height = ref(600)
|
||||||
|
|
||||||
|
// ── State interne ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type SimNode = d3.SimulationNodeDatum & { id: number; nom: string; offre: string; besoin: string }
|
||||||
|
type SimLink = d3.SimulationLinkDatum<SimNode> & { score: number; mode: string }
|
||||||
|
|
||||||
|
let simulation: d3.Simulation<SimNode, SimLink> | null = null
|
||||||
|
let svgRoot: d3.Selection<SVGSVGElement, unknown, null, undefined> | null = null
|
||||||
|
let gLinks: d3.Selection<SVGGElement, unknown, null, undefined> | null = null
|
||||||
|
let gNodes: d3.Selection<SVGGElement, unknown, null, undefined> | null = null
|
||||||
|
|
||||||
|
const isMobile = computed(() => width.value < 600)
|
||||||
|
const nodeRadius = computed(() => isMobile.value ? 22 : 28)
|
||||||
|
|
||||||
|
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function truncate(str: string, max = 10): string {
|
||||||
|
if (!str) return ''
|
||||||
|
return str.length > max ? str.slice(0, max - 1) + '…' : str
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildNodes(): SimNode[] {
|
||||||
|
return props.fiches.map(f => ({
|
||||||
|
id: f.id,
|
||||||
|
nom: f.nom,
|
||||||
|
offre: f.offre,
|
||||||
|
besoin: f.besoin,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildLinks(nodes: SimNode[]): SimLink[] {
|
||||||
|
if (!props.matches || props.matches.length === 0) return []
|
||||||
|
const nodeById = new Map(nodes.map(n => [n.id, n]))
|
||||||
|
return props.matches
|
||||||
|
.filter(m => nodeById.has(m.fromId) && nodeById.has(m.toId))
|
||||||
|
.map(m => ({
|
||||||
|
source: nodeById.get(m.fromId)!,
|
||||||
|
target: nodeById.get(m.toId)!,
|
||||||
|
score: m.score,
|
||||||
|
mode: m.mode,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
function linkColor(mode: string): string {
|
||||||
|
if (mode === 'solution') return '#22c55e'
|
||||||
|
if (mode === 'alliance') return '#f97316'
|
||||||
|
if (mode === 'surprise') return '#3b82f6'
|
||||||
|
return '#ccc'
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Drag handler ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function makeDrag(sim: d3.Simulation<SimNode, SimLink>): d3.DragBehavior<SVGGElement, SimNode, SimNode> {
|
||||||
|
return d3.drag<SVGGElement, SimNode>()
|
||||||
|
.on('start', (event, d) => {
|
||||||
|
if (!event.active) sim.alphaTarget(0.3).restart()
|
||||||
|
d.fx = d.x
|
||||||
|
d.fy = d.y
|
||||||
|
})
|
||||||
|
.on('drag', (event, d) => {
|
||||||
|
d.fx = event.x
|
||||||
|
d.fy = event.y
|
||||||
|
})
|
||||||
|
.on('end', (event, d) => {
|
||||||
|
if (!event.active) sim.alphaTarget(0)
|
||||||
|
d.fx = null
|
||||||
|
d.fy = null
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Initialisation SVG ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function initSvg() {
|
||||||
|
if (!svgEl.value) return
|
||||||
|
|
||||||
|
svgRoot = d3.select(svgEl.value)
|
||||||
|
.attr('width', width.value)
|
||||||
|
.attr('height', height.value)
|
||||||
|
|
||||||
|
svgRoot.selectAll('*').remove()
|
||||||
|
|
||||||
|
gLinks = svgRoot.append('g').attr('class', 'links')
|
||||||
|
gNodes = svgRoot.append('g').attr('class', 'nodes')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Rebuild liens (hook pour M4) ───────────────────────────────────────────
|
||||||
|
|
||||||
|
let currentNodes: SimNode[] = []
|
||||||
|
let currentLinks: SimLink[] = []
|
||||||
|
|
||||||
|
function rebuildLinks() {
|
||||||
|
currentLinks = buildLinks(currentNodes)
|
||||||
|
if (!gLinks || !simulation) return
|
||||||
|
|
||||||
|
// .join() moderne D3 pour garantir le re-rendu complet
|
||||||
|
gLinks
|
||||||
|
.selectAll<SVGLineElement, SimLink>('line')
|
||||||
|
.data(currentLinks)
|
||||||
|
.join(
|
||||||
|
enter => enter.append('line'),
|
||||||
|
update => update,
|
||||||
|
exit => exit.remove()
|
||||||
|
)
|
||||||
|
.attr('stroke', d => linkColor(d.mode))
|
||||||
|
.attr('stroke-width', d => 1 + d.score * 3)
|
||||||
|
.attr('stroke-opacity', 0.7)
|
||||||
|
.attr('marker-end', d => d.mode === 'solution' ? 'url(#arrow-solution)' : null)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Rendu complet ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
if (!svgEl.value || props.fiches.length === 0) return
|
||||||
|
|
||||||
|
initSvg()
|
||||||
|
|
||||||
|
currentNodes = buildNodes()
|
||||||
|
currentLinks = buildLinks(currentNodes)
|
||||||
|
|
||||||
|
const r = nodeRadius.value
|
||||||
|
const fontSize = isMobile.value ? 10 : 12
|
||||||
|
|
||||||
|
// Liens
|
||||||
|
gLinks!
|
||||||
|
.selectAll<SVGLineElement, SimLink>('line')
|
||||||
|
.data(currentLinks)
|
||||||
|
.join('line')
|
||||||
|
.attr('stroke', d => linkColor(d.mode))
|
||||||
|
.attr('stroke-width', d => 1 + d.score * 3)
|
||||||
|
.attr('stroke-opacity', 0.7)
|
||||||
|
.attr('marker-end', d => d.mode === 'solution' ? 'url(#arrow-solution)' : null)
|
||||||
|
|
||||||
|
// Noeuds = groupe <g> par personne
|
||||||
|
const nodeGroups = gNodes!
|
||||||
|
.selectAll<SVGGElement, SimNode>('g.node')
|
||||||
|
.data(currentNodes, d => String(d.id))
|
||||||
|
.join('g')
|
||||||
|
.attr('class', 'node')
|
||||||
|
.style('cursor', 'pointer')
|
||||||
|
.call(makeDrag(simulation!) as any)
|
||||||
|
.on('click', (_event, d) => emit('select-fiche', d.id))
|
||||||
|
|
||||||
|
// Cercle principal
|
||||||
|
nodeGroups.append('circle')
|
||||||
|
.attr('r', r)
|
||||||
|
.attr('fill', '#ffffff')
|
||||||
|
.attr('stroke', '#1B4436')
|
||||||
|
.attr('stroke-width', 2)
|
||||||
|
|
||||||
|
// Label nom
|
||||||
|
nodeGroups.append('text')
|
||||||
|
.attr('text-anchor', 'middle')
|
||||||
|
.attr('dominant-baseline', 'central')
|
||||||
|
.attr('font-size', fontSize)
|
||||||
|
.attr('font-weight', '700')
|
||||||
|
.attr('fill', '#1a1a2e')
|
||||||
|
.attr('pointer-events', 'none')
|
||||||
|
.text(d => truncate(d.nom, 10))
|
||||||
|
|
||||||
|
// Pastille offre (haut-droite, vert)
|
||||||
|
nodeGroups.append('circle')
|
||||||
|
.attr('r', 6)
|
||||||
|
.attr('cx', r * 0.65)
|
||||||
|
.attr('cy', -r * 0.65)
|
||||||
|
.attr('fill', '#22c55e')
|
||||||
|
.attr('stroke', '#fff')
|
||||||
|
.attr('stroke-width', 1.5)
|
||||||
|
|
||||||
|
// Pastille besoin (bas-droite, bleu)
|
||||||
|
nodeGroups.append('circle')
|
||||||
|
.attr('r', 6)
|
||||||
|
.attr('cx', r * 0.65)
|
||||||
|
.attr('cy', r * 0.65)
|
||||||
|
.attr('fill', '#3b82f6')
|
||||||
|
.attr('stroke', '#fff')
|
||||||
|
.attr('stroke-width', 1.5)
|
||||||
|
|
||||||
|
// Tooltip SVG natif <title>
|
||||||
|
nodeGroups.append('title')
|
||||||
|
.text(d => `${d.nom}\nOffre : ${d.offre}\nBesoin : ${d.besoin}`)
|
||||||
|
|
||||||
|
// Groupe label bulle (affiche si showLabels)
|
||||||
|
const labelGroups = nodeGroups.append('g')
|
||||||
|
.attr('class', 'label-bubble')
|
||||||
|
.attr('visibility', props.showLabels ? 'visible' : 'hidden')
|
||||||
|
|
||||||
|
// Fond bulle besoin (dessous du noeud)
|
||||||
|
labelGroups.append('rect')
|
||||||
|
.attr('class', 'bubble-besoin-bg')
|
||||||
|
.attr('x', -(r + 50))
|
||||||
|
.attr('y', r + 4)
|
||||||
|
.attr('width', 100)
|
||||||
|
.attr('height', 28)
|
||||||
|
.attr('rx', 6)
|
||||||
|
.attr('fill', '#eff6ff')
|
||||||
|
.attr('stroke', '#3b82f6')
|
||||||
|
.attr('stroke-width', 1)
|
||||||
|
|
||||||
|
// Texte besoin
|
||||||
|
labelGroups.append('text')
|
||||||
|
.attr('class', 'bubble-besoin-txt')
|
||||||
|
.attr('x', -(r) + 50)
|
||||||
|
.attr('y', r + 22)
|
||||||
|
.attr('text-anchor', 'middle')
|
||||||
|
.attr('font-size', 9)
|
||||||
|
.attr('fill', '#1e40af')
|
||||||
|
.attr('pointer-events', 'none')
|
||||||
|
.text(d => truncate(d.besoin, 18))
|
||||||
|
|
||||||
|
// Fond bulle offre (dessus du noeud)
|
||||||
|
labelGroups.append('rect')
|
||||||
|
.attr('class', 'bubble-offre-bg')
|
||||||
|
.attr('x', -(r + 50))
|
||||||
|
.attr('y', -(r + 32))
|
||||||
|
.attr('width', 100)
|
||||||
|
.attr('height', 28)
|
||||||
|
.attr('rx', 6)
|
||||||
|
.attr('fill', '#f0fdf4')
|
||||||
|
.attr('stroke', '#22c55e')
|
||||||
|
.attr('stroke-width', 1)
|
||||||
|
|
||||||
|
// Texte offre
|
||||||
|
labelGroups.append('text')
|
||||||
|
.attr('class', 'bubble-offre-txt')
|
||||||
|
.attr('x', -(r) + 50)
|
||||||
|
.attr('y', -(r + 14))
|
||||||
|
.attr('text-anchor', 'middle')
|
||||||
|
.attr('font-size', 9)
|
||||||
|
.attr('fill', '#166534')
|
||||||
|
.attr('pointer-events', 'none')
|
||||||
|
.text(d => truncate(d.offre, 18))
|
||||||
|
|
||||||
|
// Simulation
|
||||||
|
simulation = d3.forceSimulation<SimNode, SimLink>(currentNodes)
|
||||||
|
.force('link', d3.forceLink<SimNode, SimLink>(currentLinks)
|
||||||
|
.id(d => d.id)
|
||||||
|
.distance(120)
|
||||||
|
.strength(0.3))
|
||||||
|
.force('charge', d3.forceManyBody<SimNode>().strength(-400))
|
||||||
|
.force('center', d3.forceCenter(width.value / 2, height.value / 2))
|
||||||
|
.force('collide', d3.forceCollide<SimNode>().radius(r + 12))
|
||||||
|
.force('x', d3.forceX(width.value / 2).strength(0.05))
|
||||||
|
.force('y', d3.forceY(height.value / 2).strength(0.05))
|
||||||
|
.alphaDecay(0.02)
|
||||||
|
.on('tick', tick)
|
||||||
|
|
||||||
|
// Re-bind drag avec la nouvelle simulation
|
||||||
|
gNodes!.selectAll<SVGGElement, SimNode>('g.node')
|
||||||
|
.call(makeDrag(simulation) as any)
|
||||||
|
}
|
||||||
|
|
||||||
|
function tick() {
|
||||||
|
const r = nodeRadius.value
|
||||||
|
if (!gLinks || !gNodes) return
|
||||||
|
|
||||||
|
gLinks.selectAll<SVGLineElement, SimLink>('line')
|
||||||
|
.attr('x1', d => Math.max(r, Math.min(width.value - r, (d.source as SimNode).x ?? 0)))
|
||||||
|
.attr('y1', d => Math.max(r, Math.min(height.value - r, (d.source as SimNode).y ?? 0)))
|
||||||
|
.attr('x2', d => Math.max(r, Math.min(width.value - r, (d.target as SimNode).x ?? 0)))
|
||||||
|
.attr('y2', d => Math.max(r, Math.min(height.value - r, (d.target as SimNode).y ?? 0)))
|
||||||
|
|
||||||
|
gNodes.selectAll<SVGGElement, SimNode>('g.node')
|
||||||
|
.attr('transform', d => {
|
||||||
|
const x = Math.max(r, Math.min(width.value - r, d.x ?? 0))
|
||||||
|
const y = Math.max(r, Math.min(height.value - r, d.y ?? 0))
|
||||||
|
return `translate(${x},${y})`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Watch matches/mode (hook pour M4) ─────────────────────────────────────
|
||||||
|
|
||||||
|
watch(() => [props.matches, props.mode] as const, () => {
|
||||||
|
if (!simulation) return
|
||||||
|
rebuildLinks()
|
||||||
|
const newForce = d3.forceLink<SimNode, SimLink>(currentLinks)
|
||||||
|
.id(d => String(d.id))
|
||||||
|
.distance(120)
|
||||||
|
.strength(0.5)
|
||||||
|
simulation.force('link', newForce)
|
||||||
|
simulation.alpha(0.8).restart()
|
||||||
|
}, { deep: true })
|
||||||
|
|
||||||
|
// ── Watch showLabels ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
watch(() => props.showLabels, (val) => {
|
||||||
|
if (!svgEl.value) return
|
||||||
|
d3.select(svgEl.value).selectAll('.label-bubble').attr('visibility', val ? 'visible' : 'hidden')
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Watch fiches (re-render si nouvelles fiches) ───────────────────────────
|
||||||
|
|
||||||
|
watch(() => props.fiches, () => {
|
||||||
|
if (simulation) {
|
||||||
|
simulation.stop()
|
||||||
|
simulation = null
|
||||||
|
}
|
||||||
|
render()
|
||||||
|
}, { deep: true })
|
||||||
|
|
||||||
|
// ── ResizeObserver ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
let ro: ResizeObserver | null = null
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (!container.value) return
|
||||||
|
width.value = container.value.clientWidth || 800
|
||||||
|
height.value = container.value.clientHeight || 600
|
||||||
|
|
||||||
|
render()
|
||||||
|
|
||||||
|
ro = new ResizeObserver(() => {
|
||||||
|
if (!container.value) return
|
||||||
|
width.value = container.value.clientWidth || 800
|
||||||
|
height.value = container.value.clientHeight || 600
|
||||||
|
if (svgRoot) {
|
||||||
|
svgRoot.attr('width', width.value).attr('height', height.value)
|
||||||
|
}
|
||||||
|
if (simulation) {
|
||||||
|
simulation.force('center', d3.forceCenter(width.value / 2, height.value / 2))
|
||||||
|
simulation.alpha(0.3).restart()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
ro.observe(container.value!)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (simulation) simulation.stop()
|
||||||
|
if (ro) ro.disconnect()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.codev-graph-wrap {
|
||||||
|
width: 100%;
|
||||||
|
height: 70vh;
|
||||||
|
min-height: 320px;
|
||||||
|
position: relative;
|
||||||
|
background: var(--nav-bg, #fafafa);
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.codev-svg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Etat vide ── */
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-msg {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
color: var(--nav-text-muted, #6b7280);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-link {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--nav-primary-solid, #1B4436);
|
||||||
|
text-decoration: none;
|
||||||
|
border: 1.5px solid var(--nav-primary-solid, #1B4436);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.5rem 1.25rem;
|
||||||
|
transition: background 0.15s, color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-link:hover {
|
||||||
|
background: var(--nav-primary-solid, #1B4436);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Mobile ── */
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.codev-graph-wrap {
|
||||||
|
height: 65vh;
|
||||||
|
min-height: 260px;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
36
composables/useMarkdown.ts
Normal file
36
composables/useMarkdown.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
/**
|
||||||
|
* Convertit du Markdown Mistral en HTML avec inline styles.
|
||||||
|
* Inline styles = zéro dépendance CSS, fonctionne dans tout contexte Vue (scoped, v-html, etc.)
|
||||||
|
*/
|
||||||
|
export function useMarkdown() {
|
||||||
|
const S = {
|
||||||
|
p: 'style="margin:0 0 0.45em;line-height:1.6;"',
|
||||||
|
strong: 'style="font-weight:700;"',
|
||||||
|
em: 'style="font-style:italic;"',
|
||||||
|
h2: 'style="font-weight:700;display:block;margin-bottom:0.2em;"',
|
||||||
|
h3: 'style="font-weight:700;display:block;font-size:0.95em;margin-bottom:0.15em;"',
|
||||||
|
ul: 'style="margin:0.3em 0 0.3em 1.2em;padding:0;list-style:disc;"',
|
||||||
|
li: 'style="margin-bottom:0.15em;"',
|
||||||
|
a: 'style="text-decoration:underline;opacity:0.85;"',
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(text: string): string {
|
||||||
|
if (!text) return ''
|
||||||
|
let html = text
|
||||||
|
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||||
|
.replace(/^### (.+)$/gm, `<strong ${S.h3}>$1</strong>`)
|
||||||
|
.replace(/^## (.+)$/gm, `<strong ${S.h2}>$1</strong>`)
|
||||||
|
.replace(/^# (.+)$/gm, `<strong ${S.h2}>$1</strong>`)
|
||||||
|
.replace(/\*\*(.+?)\*\*/g, `<strong ${S.strong}>$1</strong>`)
|
||||||
|
.replace(/\*(.+?)\*/g, `<em ${S.em}>$1</em>`)
|
||||||
|
.replace(/^[-•]\s+(.+)$/gm, `<li ${S.li}>$1</li>`)
|
||||||
|
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, `<a href="$2" target="_blank" rel="noopener" ${S.a}>$1</a>`)
|
||||||
|
|
||||||
|
html = html.replace(/(<li[^>]*>.*<\/li>\n?)+/g, m => `<ul ${S.ul}>${m}</ul>`)
|
||||||
|
html = html.replace(/\n{2,}/g, `</p><p ${S.p}>`)
|
||||||
|
html = html.replace(/\n/g, '<br>')
|
||||||
|
return `<p ${S.p}>${html}</p>`
|
||||||
|
}
|
||||||
|
|
||||||
|
return { render }
|
||||||
|
}
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
export default defineNuxtConfig({
|
export default defineNuxtConfig({
|
||||||
modules: ['@nuxtjs/tailwindcss'],
|
modules: ['@nuxtjs/tailwindcss'],
|
||||||
css: ['~/assets/css/main.css'],
|
css: [
|
||||||
|
'~/assets/css/main.css',
|
||||||
|
'leaflet/dist/leaflet.css',
|
||||||
|
'leaflet.markercluster/dist/MarkerCluster.css',
|
||||||
|
'leaflet.markercluster/dist/MarkerCluster.Default.css',
|
||||||
|
],
|
||||||
|
|
||||||
runtimeConfig: {
|
runtimeConfig: {
|
||||||
nocodbUrl: process.env.NOCODB_URL,
|
nocodbUrl: process.env.NOCODB_URL,
|
||||||
@@ -14,16 +19,21 @@ export default defineNuxtConfig({
|
|||||||
redisUrl: process.env.REDIS_URL || 'redis://127.0.0.1:6379',
|
redisUrl: process.env.REDIS_URL || 'redis://127.0.0.1:6379',
|
||||||
resendApiKey: process.env.RESEND_API_KEY,
|
resendApiKey: process.env.RESEND_API_KEY,
|
||||||
emailJules: process.env.EMAIL_JULES || 'jules@trans-former.fr',
|
emailJules: process.env.EMAIL_JULES || 'jules@trans-former.fr',
|
||||||
|
codevTableId: '', // NUXT_CODEV_TABLE_ID
|
||||||
|
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.NUXT_RAG_PE_URL || 'http://localhost:9621',
|
||||||
},
|
},
|
||||||
|
|
||||||
// Leaflet ne fonctionne pas en SSR — forcer le rendu côté client
|
// Leaflet ne fonctionne pas en SSR — forcer le rendu côté client
|
||||||
ssr: true,
|
ssr: true,
|
||||||
|
|
||||||
vite: {
|
vite: {
|
||||||
|
cacheDir: 'C:/Users/jules/AppData/Local/nav-carte-vite-cache',
|
||||||
optimizeDeps: {
|
optimizeDeps: {
|
||||||
include: ['leaflet', 'leaflet.markercluster'],
|
include: ['leaflet', 'leaflet.markercluster', 'd3'],
|
||||||
},
|
},
|
||||||
// Éviter l'import SSR de Leaflet qui utilise window
|
|
||||||
ssr: {
|
ssr: {
|
||||||
noExternal: [],
|
noExternal: [],
|
||||||
},
|
},
|
||||||
|
|||||||
459
package-lock.json
generated
459
package-lock.json
generated
@@ -10,6 +10,7 @@
|
|||||||
"@headlessui/vue": "^1.7.23",
|
"@headlessui/vue": "^1.7.23",
|
||||||
"@types/leaflet": "^1.9.21",
|
"@types/leaflet": "^1.9.21",
|
||||||
"@types/leaflet.markercluster": "^1.5.6",
|
"@types/leaflet.markercluster": "^1.5.6",
|
||||||
|
"d3": "^7.9.0",
|
||||||
"ioredis": "^5.3.2",
|
"ioredis": "^5.3.2",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
"leaflet.markercluster": "^1.5.3",
|
"leaflet.markercluster": "^1.5.3",
|
||||||
@@ -5312,6 +5313,416 @@
|
|||||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/d3": {
|
||||||
|
"version": "7.9.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz",
|
||||||
|
"integrity": "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-array": "3",
|
||||||
|
"d3-axis": "3",
|
||||||
|
"d3-brush": "3",
|
||||||
|
"d3-chord": "3",
|
||||||
|
"d3-color": "3",
|
||||||
|
"d3-contour": "4",
|
||||||
|
"d3-delaunay": "6",
|
||||||
|
"d3-dispatch": "3",
|
||||||
|
"d3-drag": "3",
|
||||||
|
"d3-dsv": "3",
|
||||||
|
"d3-ease": "3",
|
||||||
|
"d3-fetch": "3",
|
||||||
|
"d3-force": "3",
|
||||||
|
"d3-format": "3",
|
||||||
|
"d3-geo": "3",
|
||||||
|
"d3-hierarchy": "3",
|
||||||
|
"d3-interpolate": "3",
|
||||||
|
"d3-path": "3",
|
||||||
|
"d3-polygon": "3",
|
||||||
|
"d3-quadtree": "3",
|
||||||
|
"d3-random": "3",
|
||||||
|
"d3-scale": "4",
|
||||||
|
"d3-scale-chromatic": "3",
|
||||||
|
"d3-selection": "3",
|
||||||
|
"d3-shape": "3",
|
||||||
|
"d3-time": "3",
|
||||||
|
"d3-time-format": "4",
|
||||||
|
"d3-timer": "3",
|
||||||
|
"d3-transition": "3",
|
||||||
|
"d3-zoom": "3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-array": {
|
||||||
|
"version": "3.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
||||||
|
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"internmap": "1 - 2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-axis": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-brush": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-dispatch": "1 - 3",
|
||||||
|
"d3-drag": "2 - 3",
|
||||||
|
"d3-interpolate": "1 - 3",
|
||||||
|
"d3-selection": "3",
|
||||||
|
"d3-transition": "3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-chord": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-path": "1 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-color": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-contour": {
|
||||||
|
"version": "4.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-4.0.2.tgz",
|
||||||
|
"integrity": "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-array": "^3.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-delaunay": {
|
||||||
|
"version": "6.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz",
|
||||||
|
"integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"delaunator": "5"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-dispatch": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-drag": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-dispatch": "1 - 3",
|
||||||
|
"d3-selection": "3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-dsv": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"commander": "7",
|
||||||
|
"iconv-lite": "0.6",
|
||||||
|
"rw": "1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"csv2json": "bin/dsv2json.js",
|
||||||
|
"csv2tsv": "bin/dsv2dsv.js",
|
||||||
|
"dsv2dsv": "bin/dsv2dsv.js",
|
||||||
|
"dsv2json": "bin/dsv2json.js",
|
||||||
|
"json2csv": "bin/json2dsv.js",
|
||||||
|
"json2dsv": "bin/json2dsv.js",
|
||||||
|
"json2tsv": "bin/json2dsv.js",
|
||||||
|
"tsv2csv": "bin/dsv2dsv.js",
|
||||||
|
"tsv2json": "bin/dsv2json.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-dsv/node_modules/commander": {
|
||||||
|
"version": "7.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz",
|
||||||
|
"integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-ease": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-fetch": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-fetch/-/d3-fetch-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-dsv": "1 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-force": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-dispatch": "1 - 3",
|
||||||
|
"d3-quadtree": "1 - 3",
|
||||||
|
"d3-timer": "1 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-format": {
|
||||||
|
"version": "3.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
|
||||||
|
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-geo": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-array": "2.5.0 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-hierarchy": {
|
||||||
|
"version": "3.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz",
|
||||||
|
"integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-interpolate": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-color": "1 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-path": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-polygon": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-quadtree": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-random": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-random/-/d3-random-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-scale": {
|
||||||
|
"version": "4.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
|
||||||
|
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-array": "2.10.0 - 3",
|
||||||
|
"d3-format": "1 - 3",
|
||||||
|
"d3-interpolate": "1.2.0 - 3",
|
||||||
|
"d3-time": "2.1.1 - 3",
|
||||||
|
"d3-time-format": "2 - 4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-scale-chromatic": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-color": "1 - 3",
|
||||||
|
"d3-interpolate": "1 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-selection": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-shape": {
|
||||||
|
"version": "3.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
|
||||||
|
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-path": "^3.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-time": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-array": "2 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-time-format": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-time": "1 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-timer": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-transition": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-color": "1 - 3",
|
||||||
|
"d3-dispatch": "1 - 3",
|
||||||
|
"d3-ease": "1 - 3",
|
||||||
|
"d3-interpolate": "1 - 3",
|
||||||
|
"d3-timer": "1 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"d3-selection": "2 - 3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-zoom": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-dispatch": "1 - 3",
|
||||||
|
"d3-drag": "2 - 3",
|
||||||
|
"d3-interpolate": "1 - 3",
|
||||||
|
"d3-selection": "2 - 3",
|
||||||
|
"d3-transition": "2 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/db0": {
|
"node_modules/db0": {
|
||||||
"version": "0.3.4",
|
"version": "0.3.4",
|
||||||
"resolved": "https://registry.npmjs.org/db0/-/db0-0.3.4.tgz",
|
"resolved": "https://registry.npmjs.org/db0/-/db0-0.3.4.tgz",
|
||||||
@@ -5425,6 +5836,15 @@
|
|||||||
"integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==",
|
"integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/delaunator": {
|
||||||
|
"version": "5.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.1.0.tgz",
|
||||||
|
"integrity": "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"robust-predicates": "^3.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/delegates": {
|
"node_modules/delegates": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz",
|
||||||
@@ -6480,6 +6900,18 @@
|
|||||||
"node": ">=16.17.0"
|
"node": ">=16.17.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/iconv-lite": {
|
||||||
|
"version": "0.6.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||||
|
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ieee754": {
|
"node_modules/ieee754": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
||||||
@@ -6555,6 +6987,15 @@
|
|||||||
"node": "^14.17.0 || ^16.13.0 || >=18.0.0"
|
"node": "^14.17.0 || ^16.13.0 || >=18.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/internmap": {
|
||||||
|
"version": "2.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
||||||
|
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ioredis": {
|
"node_modules/ioredis": {
|
||||||
"version": "5.10.1",
|
"version": "5.10.1",
|
||||||
"resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.10.1.tgz",
|
"resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.10.1.tgz",
|
||||||
@@ -9480,6 +9921,12 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/robust-predicates": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==",
|
||||||
|
"license": "Unlicense"
|
||||||
|
},
|
||||||
"node_modules/rollup": {
|
"node_modules/rollup": {
|
||||||
"version": "4.60.1",
|
"version": "4.60.1",
|
||||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz",
|
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz",
|
||||||
@@ -9595,6 +10042,12 @@
|
|||||||
"queue-microtask": "^1.2.2"
|
"queue-microtask": "^1.2.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/rw": {
|
||||||
|
"version": "1.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz",
|
||||||
|
"integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==",
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
|
},
|
||||||
"node_modules/safe-buffer": {
|
"node_modules/safe-buffer": {
|
||||||
"version": "5.2.1",
|
"version": "5.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||||
@@ -9633,6 +10086,12 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/safer-buffer": {
|
||||||
|
"version": "2.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||||
|
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/sax": {
|
"node_modules/sax": {
|
||||||
"version": "1.6.0",
|
"version": "1.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz",
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
"@headlessui/vue": "^1.7.23",
|
"@headlessui/vue": "^1.7.23",
|
||||||
"@types/leaflet": "^1.9.21",
|
"@types/leaflet": "^1.9.21",
|
||||||
"@types/leaflet.markercluster": "^1.5.6",
|
"@types/leaflet.markercluster": "^1.5.6",
|
||||||
|
"d3": "^7.9.0",
|
||||||
"ioredis": "^5.3.2",
|
"ioredis": "^5.3.2",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
"leaflet.markercluster": "^1.5.3",
|
"leaflet.markercluster": "^1.5.3",
|
||||||
|
|||||||
@@ -8,16 +8,12 @@
|
|||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
|
|
||||||
<!-- ══════════════════════════════════════════════════════════
|
<!-- ══════════════════════════════════════════════════════════
|
||||||
SECTION 1 - Mission AEP
|
SECTION INTRO - À propos d'AEP
|
||||||
══════════════════════════════════════════════════════════ -->
|
══════════════════════════════════════════════════════════ -->
|
||||||
<!-- TODO Jules : Écrire le pitch (~100 mots) - qui est AEP, pour qui, pourquoi, quelle promesse -->
|
|
||||||
<section class="section-mission">
|
<section class="section-mission">
|
||||||
<h1>Architecture d'Écologie Politique</h1>
|
<h1>À propos d'AEP</h1>
|
||||||
<p class="mission-text">
|
<p class="mission-text">
|
||||||
L'architecture est l'une des professions les plus complexes qui soit ; elle croise droit, technique, esthétique, économie, social, écologie - tout à la fois, tout simultanément, souvent sans filet. Paradoxalement, c'est aussi l'une des moins structurées sur le plan de l'entraide : peu de transmission horizontale, beaucoup d'isolement, une culture du chacun-pour-soi héritée d'une formation qui prépare à la compétition plus qu'à la coopération. On sort de l'école seul.e. On s'installe seul.e. On réinvente ce que d'autres ont déjà traversé.
|
AEP — Architecture d'Écologie Politique — est un commun vivant : une infrastructure d'entraide, de ressources documentées et de cartographies au service d'une profession en mutation. Ce site rassemble trois cartes (entraide, réseaux engagés, plateformes de mise en relation), un manifeste, une transparence radicale sur l'IA et le financement, et une gouvernance partagée.
|
||||||
</p>
|
|
||||||
<p class="mission-text">
|
|
||||||
Cette carte est née de cette frustration - et de cette conviction : les ressources existent, les gens qui ont réussi à sortir la tête de l'eau aussi. L'enjeu, c'est de les documenter, de les rendre accessibles, de les ajuster en temps réel grâce aux retours de la communauté. Pas un catalogue figé ; un commun vivant, au service de ceux et celles qui cherchent à faire évoluer leur pratique vers quelque chose de plus épanouissant, mieux rémunéré, au service de la société - et qui prend soin de la santé, la nôtre et celle des gens pour qui nous construisons.
|
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -209,11 +205,14 @@ useHead({ title: 'À propos - AEP' })
|
|||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background: var(--nav-bg);
|
background: var(--nav-bg);
|
||||||
padding: 1.5rem 1rem 5rem;
|
padding: 1.5rem 1rem 5rem;
|
||||||
|
overflow-x: hidden;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.apropos-inner {
|
.apropos-inner {
|
||||||
max-width: 720px;
|
max-width: 720px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Retour ──────────────────────────────────────────────────────────────────── */
|
/* ── Retour ──────────────────────────────────────────────────────────────────── */
|
||||||
@@ -322,13 +321,16 @@ useHead({ title: 'À propos - AEP' })
|
|||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--nav-text);
|
color: var(--nav-text);
|
||||||
white-space: nowrap;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.badge-detail {
|
.badge-detail {
|
||||||
font-size: 0.775rem;
|
font-size: 0.775rem;
|
||||||
color: var(--nav-text-muted);
|
color: var(--nav-text-muted);
|
||||||
white-space: nowrap;
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 560px) {
|
||||||
|
.badge-label { white-space: nowrap; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 559px) {
|
@media (max-width: 559px) {
|
||||||
|
|||||||
@@ -1,39 +1,621 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col items-center justify-center h-full gap-6" style="background: var(--nav-bg);">
|
<div class="flex h-full overflow-hidden" style="background: var(--nav-bg);">
|
||||||
<div class="text-center max-w-md px-6">
|
|
||||||
<div
|
<!-- ═══════════════════════════════════════ SIDEBAR DESKTOP (>= 1024px) -->
|
||||||
class="inline-flex items-center justify-center w-14 h-14 rounded-2xl mb-5"
|
<div class="hidden lg:block overflow-y-auto" style="width: 320px; min-width: 320px; flex-shrink: 0; border-right: 1px solid var(--nav-bg-alt); height: 100%;">
|
||||||
style="background: var(--nav-bg-alt);"
|
|
||||||
>
|
<!-- IntentionBanner s'auto-affiche via Teleport (overlay plein ecran) -->
|
||||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="color: var(--nav-text-muted);">
|
<IntentionBanner />
|
||||||
<rect x="3" y="3" width="7" height="7"/>
|
|
||||||
<rect x="14" y="3" width="7" height="7"/>
|
<!-- Filtres familles + hashtags -->
|
||||||
<rect x="14" y="14" width="7" height="7"/>
|
<HashtagFilter
|
||||||
<rect x="3" y="14" width="7" height="7"/>
|
:allHashtags="allHashtags"
|
||||||
</svg>
|
:selectedHashtags="selectedHashtags"
|
||||||
|
:selectedFamille="selectedFamille"
|
||||||
|
@update:selectedHashtags="selectedHashtags = $event"
|
||||||
|
@update:selectedFamille="selectedFamille = $event"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Separateur -->
|
||||||
|
<div style="height: 1px; background: var(--nav-bg-alt);"></div>
|
||||||
|
|
||||||
|
<!-- Barre de recherche -->
|
||||||
|
<div class="px-3 py-2" style="border-bottom: 1px solid var(--nav-bg-alt);">
|
||||||
|
<label class="sidebar-search-label" aria-label="Rechercher une structure">
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="sidebar-search-icon">
|
||||||
|
<circle cx="11" cy="11" r="8"/>
|
||||||
|
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
|
||||||
|
</svg>
|
||||||
|
<input
|
||||||
|
v-model="search"
|
||||||
|
type="search"
|
||||||
|
placeholder="Rechercher une structure..."
|
||||||
|
class="sidebar-search-input"
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
v-if="search"
|
||||||
|
type="button"
|
||||||
|
class="sidebar-search-clear"
|
||||||
|
aria-label="Effacer"
|
||||||
|
@click.stop="search = ''"
|
||||||
|
>
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<h1 class="text-2xl font-bold mb-3" style="color: var(--nav-text);">Agences Inspirantes</h1>
|
|
||||||
<p class="text-sm leading-relaxed mb-6" style="color: var(--nav-text-muted);">
|
<!-- Header compteur + reset -->
|
||||||
Cette section répertoriera les agences d'architecture qui incarnent une pratique engagée — écologie politique, auto-construction, architectures vernaculaires, sobriété.
|
<div class="flex items-center justify-between px-4 py-2" style="border-bottom: 1px solid var(--nav-bg-alt);">
|
||||||
</p>
|
<span class="text-xs font-bold uppercase tracking-widest" style="color: var(--nav-text-muted);">
|
||||||
<p class="text-xs font-semibold uppercase tracking-widest mb-6" style="color: var(--nav-text-muted); opacity: 0.6;">
|
{{ filtered.length }} structure{{ filtered.length > 1 ? 's' : '' }}
|
||||||
Bientôt disponible
|
</span>
|
||||||
</p>
|
<button
|
||||||
<NuxtLink
|
v-if="hasActiveFilters"
|
||||||
to="/"
|
@click="resetFilters"
|
||||||
class="inline-flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-semibold transition-all hover:opacity-80"
|
class="text-xs underline hover:opacity-70"
|
||||||
style="background: var(--nav-primary); color: var(--nav-text-on-primary);"
|
style="color: var(--nav-text-muted);"
|
||||||
>
|
>Effacer les filtres</button>
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" aria-hidden="true">
|
</div>
|
||||||
<line x1="19" y1="12" x2="5" y2="12"/>
|
|
||||||
<polyline points="12 19 5 12 12 5"/>
|
<!-- Liste fiches (sidebar entiere scroll - pas de scroll interne) -->
|
||||||
</svg>
|
<div class="px-3 py-2 space-y-1.5">
|
||||||
Retour à l'écosystème
|
<div v-if="pending" class="flex items-center justify-center py-8" style="color: var(--nav-text-muted);">
|
||||||
</NuxtLink>
|
Chargement...
|
||||||
|
</div>
|
||||||
|
<div v-else-if="filtered.length === 0" class="text-center py-8">
|
||||||
|
<p class="text-xs" style="color: var(--nav-text-muted);">Aucun résultat</p>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-for="structure in filtered"
|
||||||
|
:key="structure.id"
|
||||||
|
class="rounded-lg px-3 py-2 cursor-pointer transition-all"
|
||||||
|
:style="selectedId === structure.id
|
||||||
|
? `background: var(--nav-bg-alt); border-left: 3px solid ${familleColor(structure.famille_principale)}; padding-left: 9px;`
|
||||||
|
: 'background: var(--nav-bg); border-left: 3px solid transparent; padding-left: 9px;'"
|
||||||
|
@click="onSelectStructure(structure.id)"
|
||||||
|
@mouseenter="hoveredId = structure.id"
|
||||||
|
@mouseleave="hoveredId = null"
|
||||||
|
>
|
||||||
|
<div class="flex items-start justify-between gap-1.5">
|
||||||
|
<span class="font-semibold text-sm leading-snug" style="color: var(--nav-text);">{{ structure.nom }}</span>
|
||||||
|
<span
|
||||||
|
class="shrink-0 w-2.5 h-2.5 rounded-full mt-1"
|
||||||
|
:style="`background: ${familleColor(structure.famille_principale)};`"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="mt-0.5 text-xs" style="color: var(--nav-text-muted);">{{ structure.type_principal }} - {{ structure.ville }}</div>
|
||||||
|
<div v-if="structure.hashtags.length" class="mt-1 flex flex-wrap gap-1">
|
||||||
|
<span
|
||||||
|
v-for="tag in structure.hashtags.slice(0, 2)"
|
||||||
|
:key="tag"
|
||||||
|
class="text-xs"
|
||||||
|
style="color: var(--nav-text-muted);"
|
||||||
|
>{{ tag }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ═══════════════════════════════════════ ZONE CENTRALE (carte) -->
|
||||||
|
<main class="flex-1 flex flex-col overflow-hidden relative">
|
||||||
|
|
||||||
|
<!-- ── VUE DESKTOP : Onglets Métro/Outre-mer ── -->
|
||||||
|
<div class="hidden lg:flex lg:flex-1 lg:flex-col lg:overflow-hidden">
|
||||||
|
<!-- Onglets desktop -->
|
||||||
|
<div class="shrink-0 flex" style="background: var(--nav-surface); border-bottom: 1px solid var(--nav-bg-alt);">
|
||||||
|
<button
|
||||||
|
class="px-5 py-2 text-sm font-medium transition-colors"
|
||||||
|
:style="desktopMapView === 'metropole'
|
||||||
|
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
|
||||||
|
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
|
||||||
|
@click="desktopMapView = 'metropole'"
|
||||||
|
>Métropolitain</button>
|
||||||
|
<button
|
||||||
|
class="px-5 py-2 text-sm font-medium transition-colors"
|
||||||
|
:style="desktopMapView === 'outremer'
|
||||||
|
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
|
||||||
|
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
|
||||||
|
@click="desktopMapView = 'outremer'"
|
||||||
|
>Outre-mer</button>
|
||||||
|
<button
|
||||||
|
class="px-5 py-2 text-sm font-medium transition-colors"
|
||||||
|
:style="desktopMapView === 'graphe'
|
||||||
|
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
|
||||||
|
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
|
||||||
|
@click="desktopMapView = 'graphe'"
|
||||||
|
>Vue graphique</button>
|
||||||
|
<NuxtLink
|
||||||
|
to="/media"
|
||||||
|
class="px-5 py-2 text-sm font-medium transition-colors"
|
||||||
|
style="color: var(--nav-text-muted); border-bottom: 2px solid transparent;"
|
||||||
|
active-class="!color-nav-text"
|
||||||
|
>Média</NuxtLink>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Carte Métropole desktop -->
|
||||||
|
<div v-show="desktopMapView === 'metropole'" class="flex-1 flex flex-col overflow-hidden">
|
||||||
|
<div class="relative flex-1" style="min-height: 200px;">
|
||||||
|
<ClientOnly>
|
||||||
|
<NavMapV2
|
||||||
|
ref="navMapRef"
|
||||||
|
:structures="metropoleStructures"
|
||||||
|
:selectedId="selectedId"
|
||||||
|
@select-structure="onSelectStructure"
|
||||||
|
/>
|
||||||
|
<template #fallback>
|
||||||
|
<div
|
||||||
|
class="w-full h-full flex items-center justify-center"
|
||||||
|
style="background: var(--nav-bg-alt); color: var(--nav-text-muted);"
|
||||||
|
>
|
||||||
|
Chargement de la carte…
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</ClientOnly>
|
||||||
|
</div>
|
||||||
|
<ChatbotPlaceholder
|
||||||
|
@highlightOrgs="() => {}"
|
||||||
|
@applyHashtag="(tag) => { if (!selectedHashtags.includes(tag)) selectedHashtags = [...selectedHashtags, tag] }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Carte Outre-mer desktop -->
|
||||||
|
<div v-show="desktopMapView === 'outremer'" class="flex-1 overflow-y-auto" style="background: var(--nav-bg);">
|
||||||
|
<ClientOnly>
|
||||||
|
<OutremerMap
|
||||||
|
:orgs="outremerOrgsLegacy"
|
||||||
|
:selectedId="selectedIdLegacyNum"
|
||||||
|
@select-org="() => {}"
|
||||||
|
/>
|
||||||
|
<template #fallback>
|
||||||
|
<div class="flex items-center justify-center h-full text-sm" style="color: var(--nav-text-muted);">
|
||||||
|
Chargement…
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</ClientOnly>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Vue graphique desktop -->
|
||||||
|
<div v-show="desktopMapView === 'graphe'" class="flex-1 overflow-hidden flex flex-col">
|
||||||
|
<div class="flex-1 overflow-hidden relative">
|
||||||
|
<ClientOnly>
|
||||||
|
<GraphView
|
||||||
|
:data="bifurcationData"
|
||||||
|
:allHashtags="allHashtags"
|
||||||
|
:active="desktopMapView === 'graphe'"
|
||||||
|
@select-structure="onSelectStructure"
|
||||||
|
/>
|
||||||
|
<template #fallback>
|
||||||
|
<div class="flex items-center justify-center h-full" style="color: var(--nav-text-muted);">
|
||||||
|
Chargement du graphe...
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</ClientOnly>
|
||||||
|
</div>
|
||||||
|
<ChatbotPlaceholder
|
||||||
|
@highlightOrgs="() => {}"
|
||||||
|
@applyHashtag="(tag) => { if (!selectedHashtags.includes(tag)) selectedHashtags = [...selectedHashtags, tag] }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── VUE MOBILE : Onglets Métro/Outre-mer/Graphique + sheet swipable ── -->
|
||||||
|
<div class="lg:hidden shrink-0 flex" style="background: var(--nav-surface); border-bottom: 1px solid var(--nav-bg-alt);">
|
||||||
|
<button
|
||||||
|
class="flex-1 py-2 text-sm font-medium transition-colors"
|
||||||
|
:style="mobileMapView === 'metropole'
|
||||||
|
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
|
||||||
|
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
|
||||||
|
@click="mobileMapView = 'metropole'"
|
||||||
|
>Métropolitain</button>
|
||||||
|
<button
|
||||||
|
class="flex-1 py-2 text-sm font-medium transition-colors"
|
||||||
|
:style="mobileMapView === 'outremer'
|
||||||
|
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
|
||||||
|
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
|
||||||
|
@click="mobileMapView = 'outremer'"
|
||||||
|
>Outre-mer</button>
|
||||||
|
<button
|
||||||
|
class="flex-1 py-2 text-sm font-medium transition-colors"
|
||||||
|
:style="mobileMapView === 'graphe'
|
||||||
|
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
|
||||||
|
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
|
||||||
|
@click="mobileMapView = 'graphe'"
|
||||||
|
>Graphe</button>
|
||||||
|
<NuxtLink
|
||||||
|
to="/media"
|
||||||
|
class="flex-1 py-2 text-sm font-medium transition-colors text-center"
|
||||||
|
style="color: var(--nav-text-muted); border-bottom: 2px solid transparent;"
|
||||||
|
>Média</NuxtLink>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="lg:hidden flex-1 relative overflow-hidden">
|
||||||
|
<!-- Carte mobile Métropole -->
|
||||||
|
<div v-show="mobileMapView === 'metropole'" class="absolute inset-0">
|
||||||
|
<ClientOnly>
|
||||||
|
<NavMapV2
|
||||||
|
ref="navMapMobileRef"
|
||||||
|
:structures="metropoleStructures"
|
||||||
|
:selectedId="selectedId"
|
||||||
|
@select-structure="onSelectStructureMobile"
|
||||||
|
/>
|
||||||
|
<template #fallback>
|
||||||
|
<div class="w-full h-full flex items-center justify-center" style="background: var(--nav-bg-alt); color: var(--nav-text-muted);">
|
||||||
|
Chargement de la carte…
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</ClientOnly>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Carte mobile Outre-mer -->
|
||||||
|
<div v-show="mobileMapView === 'outremer'" class="absolute inset-0 overflow-y-auto" style="background: var(--nav-bg);">
|
||||||
|
<ClientOnly>
|
||||||
|
<OutremerMap
|
||||||
|
:orgs="outremerOrgsLegacy"
|
||||||
|
:selectedId="selectedIdLegacyNum"
|
||||||
|
@select-org="() => {}"
|
||||||
|
/>
|
||||||
|
<template #fallback>
|
||||||
|
<div class="flex items-center justify-center h-48" style="color: var(--nav-text-muted);">
|
||||||
|
Chargement…
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</ClientOnly>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Vue graphique mobile -->
|
||||||
|
<div v-show="mobileMapView === 'graphe'" class="absolute inset-0 overflow-hidden" style="background: var(--nav-bg);">
|
||||||
|
<ClientOnly>
|
||||||
|
<GraphView
|
||||||
|
:data="bifurcationData"
|
||||||
|
:allHashtags="allHashtags"
|
||||||
|
:active="mobileMapView === 'graphe'"
|
||||||
|
@select-structure="onSelectStructureMobile"
|
||||||
|
/>
|
||||||
|
<template #fallback>
|
||||||
|
<div class="flex items-center justify-center h-48" style="color: var(--nav-text-muted);">
|
||||||
|
Chargement du graphe…
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</ClientOnly>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bottom sheet swipable (masqué en vue graphique pour ne pas occulter le canvas) -->
|
||||||
|
<ClientOnly v-if="mobileMapView !== 'graphe'">
|
||||||
|
<MobileSheet :resultCount="filtered.length" :pending="pending">
|
||||||
|
<!-- Bandeau intention mobile -->
|
||||||
|
<div class="px-3 py-2" style="background: var(--bifurc-banner-bg, #faf8f5); border-bottom: 1px solid var(--bifurc-banner-border, #e0d8cc);">
|
||||||
|
<p class="text-xs leading-relaxed" style="color: var(--bifurc-banner-text, #2c2416); margin: 0;">
|
||||||
|
120 réseaux, collectifs et agences où des pensées écologiques deviennent des pratiques d'architecture.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filtres hashtags mobile -->
|
||||||
|
<div class="px-3 py-2" style="border-bottom: 1px solid var(--nav-bg-alt);">
|
||||||
|
<HashtagFilter
|
||||||
|
:allHashtags="allHashtags"
|
||||||
|
:selectedHashtags="selectedHashtags"
|
||||||
|
:selectedFamille="selectedFamille"
|
||||||
|
@update:selectedHashtags="selectedHashtags = $event"
|
||||||
|
@update:selectedFamille="selectedFamille = $event"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Barre recherche mobile -->
|
||||||
|
<div class="px-3 pt-2 pb-2" style="border-bottom: 1px solid var(--nav-bg-alt);">
|
||||||
|
<label class="mobile-search-label" aria-label="Rechercher une structure">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="color: var(--nav-text-muted); flex-shrink: 0;">
|
||||||
|
<circle cx="11" cy="11" r="8"/>
|
||||||
|
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
|
||||||
|
</svg>
|
||||||
|
<input
|
||||||
|
v-model="search"
|
||||||
|
type="search"
|
||||||
|
placeholder="Rechercher…"
|
||||||
|
class="mobile-search-input"
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
v-if="search"
|
||||||
|
type="button"
|
||||||
|
class="mobile-search-clear"
|
||||||
|
aria-label="Effacer"
|
||||||
|
@click.stop="search = ''"
|
||||||
|
>
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
v-if="hasActiveFilters"
|
||||||
|
@click="resetFilters"
|
||||||
|
class="mt-1 text-xs"
|
||||||
|
style="color: var(--nav-text-muted); text-decoration: underline;"
|
||||||
|
>Effacer les filtres</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Liste fiches mobile -->
|
||||||
|
<div class="px-3 py-2">
|
||||||
|
<div class="text-xs font-bold uppercase tracking-wide mb-2" style="color: var(--nav-text-muted);">
|
||||||
|
{{ filtered.length }} structure{{ filtered.length > 1 ? 's' : '' }}
|
||||||
|
</div>
|
||||||
|
<div v-if="pending" class="flex items-center justify-center py-8" style="color: var(--nav-text-muted);">
|
||||||
|
Chargement des fiches…
|
||||||
|
</div>
|
||||||
|
<div v-else-if="filtered.length === 0" class="text-center py-8">
|
||||||
|
<p class="text-sm mb-2" style="color: var(--nav-text-muted);">Aucun résultat pour ces filtres.</p>
|
||||||
|
<button @click="resetFilters" class="text-sm underline" style="color: var(--nav-primary-solid);">
|
||||||
|
Effacer les filtres
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div
|
||||||
|
v-for="structure in filtered"
|
||||||
|
:key="structure.id"
|
||||||
|
class="block rounded-lg p-3 transition-all cursor-pointer"
|
||||||
|
:style="selectedId === structure.id
|
||||||
|
? `background: var(--nav-bg-alt); border-left: 3px solid ${familleColor(structure.famille_principale)};`
|
||||||
|
: 'background: var(--nav-surface); border-left: 3px solid transparent;'"
|
||||||
|
@click="onSelectStructureMobile(structure.id)"
|
||||||
|
>
|
||||||
|
<div class="flex items-start justify-between gap-2">
|
||||||
|
<span class="font-semibold text-sm leading-snug" style="color: var(--nav-text);">{{ structure.nom }}</span>
|
||||||
|
<span
|
||||||
|
class="shrink-0 w-2.5 h-2.5 rounded-full mt-1"
|
||||||
|
:style="`background: ${familleColor(structure.famille_principale)};`"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="mt-0.5 text-xs" style="color: var(--nav-text-muted);">{{ structure.type_principal }} · {{ structure.ville }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</MobileSheet>
|
||||||
|
</ClientOnly>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- ═══════════════════════════════════════ MODAL FICHE V2 (desktop) -->
|
||||||
|
<FicheModalV2
|
||||||
|
v-model="ficheModalOpen"
|
||||||
|
:structureId="ficheModalId"
|
||||||
|
:data="bifurcationData"
|
||||||
|
@update:structureId="ficheModalId = $event"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- ═══════════════════════════════════════ BOUTON CHATBOT FLOTTANT (mobile) -->
|
||||||
|
<button
|
||||||
|
class="lg:hidden fixed bottom-6 right-4 z-[1000] flex items-center gap-2 px-4 rounded-full shadow-lg"
|
||||||
|
style="
|
||||||
|
height: 48px;
|
||||||
|
background: var(--nav-primary);
|
||||||
|
opacity: 0.92;
|
||||||
|
color: var(--nav-text-on-primary);
|
||||||
|
box-shadow: 0 4px 16px rgba(26,34,56,0.25);
|
||||||
|
font-family: var(--nav-font);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
"
|
||||||
|
aria-label="Ouvrir l'assistant Chatbot"
|
||||||
|
@click="chatbotOpen = true"
|
||||||
|
>
|
||||||
|
<svg width="18" height="18" 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>Chatbot</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- ═══════════════════════════════════════ CHATBOT BOTTOM SHEET (mobile) -->
|
||||||
|
<ChatbotReseaux
|
||||||
|
:modelValue="chatbotOpen"
|
||||||
|
@update:modelValue="chatbotOpen = $event"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- ═══════════════════════════════════════ CHATBOT PENSEES (desktop, tous onglets) -->
|
||||||
|
<ClientOnly>
|
||||||
|
<ChatbotPensees />
|
||||||
|
</ClientOnly>
|
||||||
|
|
||||||
|
<!-- ═══════════════════════════════════════ POP-UP MISSION RÉSEAUX AEP -->
|
||||||
|
<button
|
||||||
|
class="reseaux-info-btn"
|
||||||
|
type="button"
|
||||||
|
@click="missionOpen = true"
|
||||||
|
aria-label="À propos des réseaux AEP cartographiés"
|
||||||
|
title="À propos de cette carte"
|
||||||
|
>
|
||||||
|
<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">
|
||||||
|
<circle cx="12" cy="12" r="10"/>
|
||||||
|
<line x1="12" y1="16" x2="12" y2="12"/>
|
||||||
|
<line x1="12" y1="8" x2="12.01" y2="8"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<MissionPopup
|
||||||
|
:modelValue="missionOpen"
|
||||||
|
@update:modelValue="missionOpen = $event"
|
||||||
|
title="Réseaux AEP — l'architecture qui s'engage"
|
||||||
|
ctaLabel="Explorer les 120 réseaux"
|
||||||
|
storageKey="aep_reseaux_seen"
|
||||||
|
>
|
||||||
|
<p class="mission-text">
|
||||||
|
Cette carte rassemble <strong>120 réseaux, collectifs et agences</strong> qui pratiquent une architecture engagée — écologique, politique, biorégionale. Ce ne sont pas seulement des agences « vertes » : ce sont celles et ceux qui assument des positions, refusent des projets, expérimentent des modèles de gouvernance, mettent leurs ressources et leurs savoirs en commun.
|
||||||
|
</p>
|
||||||
|
<p class="mission-text">
|
||||||
|
Six familles structurent la cartographie : militants, agences engagées, collectifs de production, ressources communes, recherche, formations alternatives. Filtre par hashtag, ouvre la fiche d'une structure, navigue le graphe (3<sup>e</sup> onglet) pour voir les affinités. Si tu animes ou connais un réseau qui devrait y être : <NuxtLink to="/contribuer" @click.stop>propose-le</NuxtLink>.
|
||||||
|
</p>
|
||||||
|
</MissionPopup>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
useHead({ title: 'Agences Inspirantes — AEP (bientôt disponible)' })
|
import type { ReseauxBifurcationData, StructureV2 } from '~/types/structure-v2'
|
||||||
|
|
||||||
|
// ── Couleurs familles ──────────────────────────────────────────────────────
|
||||||
|
const FAMILLE_COLORS: Record<number, string> = {
|
||||||
|
1: '#a85d3e',
|
||||||
|
2: '#c4a472',
|
||||||
|
3: '#d4a017',
|
||||||
|
4: '#5a7a4a',
|
||||||
|
5: '#3d6a8c',
|
||||||
|
6: '#6b3fa0',
|
||||||
|
}
|
||||||
|
|
||||||
|
function familleColor(f: number): string {
|
||||||
|
return FAMILLE_COLORS[f] ?? '#888'
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── État UI ────────────────────────────────────────────────────────────────
|
||||||
|
const selectedId = ref<string | null>(null)
|
||||||
|
const hoveredId = ref<string | null>(null)
|
||||||
|
const ficheModalOpen = ref(false)
|
||||||
|
const ficheModalId = ref<string | null>(null)
|
||||||
|
const chatbotOpen = ref(false)
|
||||||
|
const mobileMapView = ref<'metropole' | 'outremer' | 'graphe'>('metropole')
|
||||||
|
const desktopMapView = ref<'metropole' | 'outremer' | 'graphe'>('metropole')
|
||||||
|
const missionOpen = ref(false)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
try {
|
||||||
|
if (!localStorage.getItem('aep_reseaux_seen')) {
|
||||||
|
missionOpen.value = true
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Filtres
|
||||||
|
const search = ref('')
|
||||||
|
const selectedFamille = ref<number | null>(null)
|
||||||
|
const selectedHashtags = ref<string[]>([])
|
||||||
|
|
||||||
|
// Refs cartes
|
||||||
|
const navMapRef = ref<any>(null)
|
||||||
|
const navMapMobileRef = ref<any>(null)
|
||||||
|
|
||||||
|
// ── Données V2 - JSON statique ─────────────────────────────────────────────
|
||||||
|
const bifurcationData = ref<ReseauxBifurcationData | null>(null)
|
||||||
|
const pending = ref(true)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
bifurcationData.value = await $fetch<ReseauxBifurcationData>('/data/reseaux-bifurcation.json')
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Erreur chargement reseaux-bifurcation.json', e)
|
||||||
|
} finally {
|
||||||
|
pending.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const structures = computed<StructureV2[]>(() => bifurcationData.value?.structures ?? [])
|
||||||
|
|
||||||
|
// Tous les hashtags uniques triés
|
||||||
|
const allHashtags = computed<string[]>(() => {
|
||||||
|
const set = new Set<string>()
|
||||||
|
structures.value.forEach(s => s.hashtags.forEach(h => set.add(h)))
|
||||||
|
return Array.from(set).sort()
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Filtrage ───────────────────────────────────────────────────────────────
|
||||||
|
const filtered = computed<StructureV2[]>(() => {
|
||||||
|
let result = structures.value
|
||||||
|
|
||||||
|
// Filtre texte
|
||||||
|
if (search.value.trim()) {
|
||||||
|
const q = search.value.toLowerCase()
|
||||||
|
result = result.filter(
|
||||||
|
s =>
|
||||||
|
s.nom.toLowerCase().includes(q) ||
|
||||||
|
s.ville.toLowerCase().includes(q) ||
|
||||||
|
s.description_courte.toLowerCase().includes(q) ||
|
||||||
|
s.hashtags.some(h => h.toLowerCase().includes(q))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filtre famille - F6 = badge_f6_recherche_politique, pas famille_principale
|
||||||
|
if (selectedFamille.value !== null) {
|
||||||
|
if (selectedFamille.value === 6) {
|
||||||
|
result = result.filter(s => (s.badges as any)?.f6_recherche_politique === true)
|
||||||
|
} else {
|
||||||
|
result = result.filter(
|
||||||
|
s => s.famille_principale === selectedFamille.value ||
|
||||||
|
(s.familles_secondaires ?? []).includes(selectedFamille.value!)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filtre hashtags (AND logique si plusieurs)
|
||||||
|
if (selectedHashtags.value.length) {
|
||||||
|
result = result.filter(
|
||||||
|
s => selectedHashtags.value.every(h => s.hashtags.includes(h))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
|
||||||
|
const hasActiveFilters = computed(
|
||||||
|
() => !!search.value || selectedFamille.value !== null || selectedHashtags.value.length > 0
|
||||||
|
)
|
||||||
|
|
||||||
|
function resetFilters() {
|
||||||
|
search.value = ''
|
||||||
|
selectedFamille.value = null
|
||||||
|
selectedHashtags.value = []
|
||||||
|
}
|
||||||
|
|
||||||
|
// Structures métropole (pays != DOM-TOM, et avec coordonnées)
|
||||||
|
// Pour simplifier : toutes les structures (la carte gère les sans-coords)
|
||||||
|
const metropoleStructures = computed<StructureV2[]>(() => filtered.value)
|
||||||
|
|
||||||
|
// Outre-mer : pas de structures V2 DOM-TOM pour l'instant - garder le composant existant vide
|
||||||
|
// OutremerMap attend le format Org legacy - on passe un tableau vide
|
||||||
|
const outremerOrgsLegacy = computed(() => [])
|
||||||
|
const selectedIdLegacyNum = computed(() => null)
|
||||||
|
|
||||||
|
// ── Sélection ─────────────────────────────────────────────────────────────
|
||||||
|
function onSelectStructure(id: string) {
|
||||||
|
selectedId.value = selectedId.value === id ? null : id
|
||||||
|
if (typeof window !== 'undefined' && window.innerWidth >= 1024) {
|
||||||
|
ficheModalId.value = id
|
||||||
|
ficheModalOpen.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSelectStructureMobile(id: string) {
|
||||||
|
selectedId.value = id
|
||||||
|
ficheModalId.value = id
|
||||||
|
ficheModalOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
useHead({ title: "AEP - Réseaux de bifurcation architecturale" })
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.reseaux-info-btn {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 24px;
|
||||||
|
left: 16px;
|
||||||
|
z-index: 1000;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: none;
|
||||||
|
background: var(--nav-surface);
|
||||||
|
color: var(--nav-text-muted);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-shadow: 0 2px 12px rgba(26,34,56,0.18);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.15s, transform 0.1s;
|
||||||
|
}
|
||||||
|
.reseaux-info-btn:hover { opacity: 0.85; transform: translateY(-1px); color: var(--nav-text); }
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.reseaux-info-btn { bottom: 16px; left: 340px; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
550
pages/codev/carto.vue
Normal file
550
pages/codev/carto.vue
Normal file
@@ -0,0 +1,550 @@
|
|||||||
|
<template>
|
||||||
|
<div class="codev-carto">
|
||||||
|
|
||||||
|
<header class="carto-header">
|
||||||
|
<h1>Carto entraide</h1>
|
||||||
|
<p class="carto-subtitle">
|
||||||
|
<template v-if="pending">Chargement...</template>
|
||||||
|
<template v-else>
|
||||||
|
{{ fiches.length }} fiche{{ fiches.length !== 1 ? 's' : '' }} - clique sur un nom pour voir le detail
|
||||||
|
</template>
|
||||||
|
</p>
|
||||||
|
<NuxtLink to="/codev/qr" class="qr-link" title="QR Code">[ QR ]</NuxtLink>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="codev-tabs">
|
||||||
|
<button :class="{ active: tab === 'carto' }" @click="tab = 'carto'" type="button">Carto</button>
|
||||||
|
<button :class="{ active: tab === 'annuaire' }" @click="tab = 'annuaire'" type="button">Annuaire</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="tab === 'carto'">
|
||||||
|
<div class="show-labels-bar">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
:class="{ active: showLabels }"
|
||||||
|
@click="showLabels = !showLabels"
|
||||||
|
>
|
||||||
|
{{ showLabels ? 'Masquer besoins/offres' : 'Montrer besoins/offres' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ClientOnly>
|
||||||
|
<CodevGraph
|
||||||
|
:fiches="fiches"
|
||||||
|
:matches="matches"
|
||||||
|
:mode="mode"
|
||||||
|
:show-labels="showLabels"
|
||||||
|
@select-fiche="onSelectFiche"
|
||||||
|
/>
|
||||||
|
<template #fallback>
|
||||||
|
<div class="graph-fallback">Chargement du graphe...</div>
|
||||||
|
</template>
|
||||||
|
</ClientOnly>
|
||||||
|
|
||||||
|
<!-- Bandeau info mode actif -->
|
||||||
|
<div v-if="mode !== 'none'" class="mode-banner">
|
||||||
|
<span>
|
||||||
|
Mode {{ MODE_LABELS[mode] }} actif -
|
||||||
|
{{ matches.length }} connexion{{ matches.length !== 1 ? 's' : '' }} trouvee{{ matches.length !== 1 ? 's' : '' }}.
|
||||||
|
</span>
|
||||||
|
<button class="banner-clear" @click="setMode('none')" type="button">Effacer</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Boutons matching -->
|
||||||
|
<div class="matching-controls">
|
||||||
|
<button
|
||||||
|
:class="{ active: mode === 'solution' }"
|
||||||
|
style="--mode-color: #22c55e"
|
||||||
|
@click="setMode('solution')"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Solution
|
||||||
|
<span class="hint">besoin - compétence</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
:class="{ active: mode === 'alliance' }"
|
||||||
|
style="--mode-color: #f97316"
|
||||||
|
@click="setMode('alliance')"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Alliance
|
||||||
|
<span class="hint">besoins partagés</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="mode !== 'none'"
|
||||||
|
class="reset"
|
||||||
|
@click="setMode('none')"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Effacer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="tab === 'annuaire'" class="annuaire-wrap">
|
||||||
|
|
||||||
|
<div v-if="fiches.length === 0" class="list-empty">
|
||||||
|
Aucune fiche. <NuxtLink to="/codev/fiche">Ajouter la mienne</NuxtLink>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="annuaire-scroll">
|
||||||
|
<table class="annuaire-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="col-nom">Prénom</th>
|
||||||
|
<th class="col-besoin">Besoin</th>
|
||||||
|
<th class="col-offre">Ce que j'offre</th>
|
||||||
|
<th v-if="isAdmin" class="col-actions"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="f in fiches" :key="f.id" @click="navigateTo(`/codev/fiche?id=${f.id}`)" class="annuaire-row">
|
||||||
|
<td class="col-nom">{{ f.nom }}</td>
|
||||||
|
<td class="col-besoin">{{ f.besoin }}</td>
|
||||||
|
<td class="col-offre">{{ f.offre }}</td>
|
||||||
|
<td v-if="isAdmin" class="col-actions">
|
||||||
|
<button @click.stop="deleteFiche(f.id)" class="delete-btn" type="button" title="Supprimer">✕</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="annuaire-hint">Clique sur une ligne pour modifier la fiche</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- FAB ajouter une fiche -->
|
||||||
|
<NuxtLink to="/codev/fiche" class="fab-add" title="Ajouter ma fiche" aria-label="Ajouter une fiche">
|
||||||
|
+
|
||||||
|
</NuxtLink>
|
||||||
|
|
||||||
|
<Transition name="sheet">
|
||||||
|
<div v-if="selectedFiche" class="bottom-sheet" @click.self="selectedFiche = null">
|
||||||
|
<div class="sheet-content">
|
||||||
|
<div class="sheet-handle"></div>
|
||||||
|
<div class="sheet-name">{{ selectedFiche.nom }}</div>
|
||||||
|
<div class="sheet-section">
|
||||||
|
<span class="sheet-label">Besoin</span>
|
||||||
|
<p class="sheet-text">{{ selectedFiche.besoin }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="sheet-section">
|
||||||
|
<span class="sheet-label">Ce que j'apporte</span>
|
||||||
|
<p class="sheet-text">{{ selectedFiche.offre }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="sheet-tags" v-if="selectedFiche.hashtags.length">
|
||||||
|
<span v-for="t in selectedFiche.hashtags" :key="t" class="sheet-tag">#{{ t }}</span>
|
||||||
|
</div>
|
||||||
|
<NuxtLink :to="`/codev/fiche?id=${selectedFiche.id}`" class="sheet-edit-btn">Modifier cette fiche</NuxtLink>
|
||||||
|
<button class="sheet-close" @click="selectedFiche = null" type="button">Fermer</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { CodevFiche, CodevMatch } from '~/types/codev'
|
||||||
|
import { computeMatches } from '~/utils/codev/matching'
|
||||||
|
|
||||||
|
useHead({ title: 'Carto - Co-developpement' })
|
||||||
|
|
||||||
|
const { data, pending, refresh } = await useFetch<{ list: CodevFiche[] }>('/api/codev/fiches')
|
||||||
|
const fiches = computed(() => data.value?.list ?? [])
|
||||||
|
|
||||||
|
const matches = ref<CodevMatch[]>([])
|
||||||
|
const mode = ref<'none' | 'solution' | 'alliance' | 'surprise'>('none')
|
||||||
|
const showLabels = ref(false)
|
||||||
|
const tab = ref<'carto' | 'annuaire'>('carto')
|
||||||
|
const selectedFiche = ref<CodevFiche | null>(null)
|
||||||
|
const isMobileView = typeof window !== 'undefined' ? window.innerWidth < 600 : false
|
||||||
|
|
||||||
|
const isAdmin = ref(false)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
const r = await $fetch<{ admin: boolean }>('/api/codev/me')
|
||||||
|
isAdmin.value = r.admin
|
||||||
|
} catch { isAdmin.value = false }
|
||||||
|
})
|
||||||
|
|
||||||
|
const MODE_LABELS: Record<string, string> = {
|
||||||
|
solution: 'Solution',
|
||||||
|
alliance: 'Alliance',
|
||||||
|
surprise: 'Surprise',
|
||||||
|
}
|
||||||
|
|
||||||
|
function setMode(newMode: 'none' | 'solution' | 'alliance' | 'surprise') {
|
||||||
|
mode.value = newMode
|
||||||
|
if (newMode === 'none') {
|
||||||
|
matches.value = []
|
||||||
|
} else {
|
||||||
|
matches.value = computeMatches(fiches.value, newMode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSelectFiche(id: number) {
|
||||||
|
if (isMobileView) {
|
||||||
|
selectedFiche.value = fiches.value.find(f => f.id === id) ?? null
|
||||||
|
} else {
|
||||||
|
navigateTo(`/codev/fiche?id=${id}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteFiche(id: number) {
|
||||||
|
if (!confirm('Supprimer la fiche ?')) return
|
||||||
|
await $fetch(`/api/codev/fiches/${id}`, { method: 'DELETE' })
|
||||||
|
await refresh()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.codev-carto {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: var(--nav-bg, #fafafa);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 1.25rem 1rem 2rem;
|
||||||
|
gap: 1rem;
|
||||||
|
max-width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── En-tete ── */
|
||||||
|
|
||||||
|
.carto-header {
|
||||||
|
text-align: center;
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.carto-header h1 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--nav-text, #1a1a2e);
|
||||||
|
margin: 0 0 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.carto-subtitle {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--nav-text-muted, #6b7280);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Fallback ── */
|
||||||
|
|
||||||
|
.graph-fallback {
|
||||||
|
width: 100%;
|
||||||
|
height: 70vh;
|
||||||
|
min-height: 320px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--nav-text-muted, #6b7280);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
background: var(--nav-bg-alt, #f3f4f6);
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Bandeau mode actif ── */
|
||||||
|
|
||||||
|
.mode-banner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.5rem 0.875rem;
|
||||||
|
background: #f0fdf4;
|
||||||
|
border: 1px solid #bbf7d0;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #166534;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner-clear {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #166534;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid #166534;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.2rem 0.6rem;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner-clear:hover {
|
||||||
|
background: #166534;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Boutons matching ── */
|
||||||
|
|
||||||
|
.matching-controls {
|
||||||
|
position: sticky;
|
||||||
|
bottom: 0;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
background: rgba(255, 255, 255, 0.95);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
border-top: 1px solid #e5e7eb;
|
||||||
|
margin: 0 -1rem -2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.matching-controls button {
|
||||||
|
flex: 1;
|
||||||
|
padding: 12px 8px;
|
||||||
|
border: 1px solid #d0d4dc;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: white;
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.matching-controls button .hint {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #6b7280;
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.matching-controls button.active {
|
||||||
|
background: var(--mode-color, #1B4436);
|
||||||
|
color: white;
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.matching-controls button.active .hint {
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.matching-controls button.reset {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: #f3f4f6;
|
||||||
|
border-color: #d0d4dc;
|
||||||
|
color: #374151;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.matching-controls button.reset:hover {
|
||||||
|
background: #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 500px) {
|
||||||
|
.matching-controls {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
margin: 0 -0.75rem -1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.matching-controls button.reset {
|
||||||
|
grid-column: span 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Toggle besoins/offres ── */
|
||||||
|
|
||||||
|
.show-labels-bar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.show-labels-bar button {
|
||||||
|
border: 1px solid #d0d4dc;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: white;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #374151;
|
||||||
|
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.show-labels-bar button.active {
|
||||||
|
background: #1B4436;
|
||||||
|
color: white;
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── FAB ajouter ── */
|
||||||
|
|
||||||
|
.fab-add {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 80px;
|
||||||
|
right: 16px;
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #1B4436;
|
||||||
|
color: white;
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 300;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
text-decoration: none;
|
||||||
|
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.18);
|
||||||
|
z-index: 100;
|
||||||
|
transition: transform 0.15s, opacity 0.15s;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fab-add:hover {
|
||||||
|
transform: scale(1.08);
|
||||||
|
opacity: 0.92;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Tabs ── */
|
||||||
|
|
||||||
|
.codev-tabs { display: flex; gap: 4px; background: #f3f4f6; border-radius: 10px; padding: 4px; }
|
||||||
|
.codev-tabs button { flex: 1; padding: 8px 4px; border: none; border-radius: 7px; background: transparent; font-size: 0.875rem; font-weight: 500; cursor: pointer; color: #6b7280; transition: all 0.15s; }
|
||||||
|
.codev-tabs button.active { background: white; color: #1a1a2e; font-weight: 600; box-shadow: 0 1px 3px rgba(0,0,0,0.08); }
|
||||||
|
|
||||||
|
/* ── List view ── */
|
||||||
|
|
||||||
|
.list-view { display: flex; flex-direction: column; gap: 12px; padding: 8px 0; }
|
||||||
|
.list-card { background: white; border: 1px solid #e5e7eb; border-radius: 10px; padding: 14px 16px; display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.list-card-name { font-weight: 700; font-size: 0.95rem; color: #1a1a2e; }
|
||||||
|
.list-card-text { font-size: 0.875rem; color: #4b5563; margin: 0; line-height: 1.5; }
|
||||||
|
.list-card-link { font-size: 0.8rem; color: #1B4436; text-decoration: none; align-self: flex-end; }
|
||||||
|
.list-empty { text-align: center; color: #6b7280; font-size: 0.9rem; }
|
||||||
|
|
||||||
|
/* ── Bottom sheet ── */
|
||||||
|
|
||||||
|
.bottom-sheet { position: fixed; inset: 0; background: rgba(0,0,0,0.4); z-index: 200; display: flex; align-items: flex-end; }
|
||||||
|
.sheet-content { background: white; border-radius: 16px 16px 0 0; padding: 16px 20px 32px; width: 100%; display: flex; flex-direction: column; gap: 12px; max-height: 80vh; overflow-y: auto; }
|
||||||
|
.sheet-handle { width: 36px; height: 4px; background: #d1d5db; border-radius: 2px; align-self: center; margin-bottom: 4px; }
|
||||||
|
.sheet-name { font-size: 1.1rem; font-weight: 700; color: #1a1a2e; }
|
||||||
|
.sheet-section { display: flex; flex-direction: column; gap: 4px; }
|
||||||
|
.sheet-label { font-size: 0.75rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: #6b7280; }
|
||||||
|
.sheet-text { font-size: 0.9rem; color: #374151; margin: 0; line-height: 1.5; }
|
||||||
|
.sheet-tags { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||||
|
.sheet-tag { font-size: 0.75rem; background: #f3f4f6; color: #374151; padding: 2px 8px; border-radius: 12px; }
|
||||||
|
.sheet-edit-btn { display: block; text-align: center; background: #1B4436; color: white; border-radius: 8px; padding: 12px; text-decoration: none; font-weight: 600; }
|
||||||
|
.sheet-close { background: transparent; border: 1px solid #d1d5db; border-radius: 8px; padding: 10px; color: #6b7280; cursor: pointer; font-size: 0.875rem; }
|
||||||
|
.sheet-enter-active, .sheet-leave-active { transition: opacity 0.2s; }
|
||||||
|
.sheet-enter-from, .sheet-leave-to { opacity: 0; }
|
||||||
|
|
||||||
|
/* ── QR link ── */
|
||||||
|
|
||||||
|
.qr-link {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #9ca3af;
|
||||||
|
text-decoration: none;
|
||||||
|
align-self: flex-end;
|
||||||
|
}
|
||||||
|
.qr-link:hover { color: #6b7280; }
|
||||||
|
|
||||||
|
/* ── Annuaire ── */
|
||||||
|
|
||||||
|
.annuaire-wrap {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.annuaire-scroll {
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.annuaire-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
min-width: 480px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.annuaire-table thead tr {
|
||||||
|
background: #f9fafb;
|
||||||
|
border-bottom: 2px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.annuaire-table th {
|
||||||
|
padding: 10px 14px;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: #6b7280;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.annuaire-table td {
|
||||||
|
padding: 12px 14px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #374151;
|
||||||
|
vertical-align: top;
|
||||||
|
border-bottom: 1px solid #f3f4f6;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.annuaire-row {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.12s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.annuaire-row:hover { background: #f9fafb; }
|
||||||
|
.annuaire-row:last-child td { border-bottom: none; }
|
||||||
|
|
||||||
|
.col-nom {
|
||||||
|
position: sticky;
|
||||||
|
left: 0;
|
||||||
|
z-index: 2;
|
||||||
|
background: #ffffff;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1a1a2e !important;
|
||||||
|
white-space: nowrap;
|
||||||
|
min-width: 80px;
|
||||||
|
border-right: 2px solid #e5e7eb;
|
||||||
|
box-shadow: 2px 0 6px rgba(0,0,0,0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.annuaire-row:hover .col-nom { background: #f9fafb; }
|
||||||
|
thead tr .col-nom { background: #f9fafb; z-index: 3; }
|
||||||
|
|
||||||
|
.col-besoin { min-width: 200px; max-width: 260px; }
|
||||||
|
.col-offre { min-width: 200px; max-width: 260px; }
|
||||||
|
|
||||||
|
.annuaire-hint {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #9ca3af;
|
||||||
|
text-align: center;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.col-actions { width: 40px; text-align: center; }
|
||||||
|
.delete-btn {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #ef4444;
|
||||||
|
font-size: 1rem;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: background 0.1s;
|
||||||
|
}
|
||||||
|
.delete-btn:hover { background: #fef2f2; }
|
||||||
|
|
||||||
|
/* ── Mobile ── */
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.codev-carto {
|
||||||
|
padding: 1rem 0.75rem 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.carto-header h1 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
406
pages/codev/demo.vue
Normal file
406
pages/codev/demo.vue
Normal file
@@ -0,0 +1,406 @@
|
|||||||
|
<template>
|
||||||
|
<div class="codev-demo">
|
||||||
|
|
||||||
|
<header class="demo-header">
|
||||||
|
<span class="demo-badge">DEMO</span>
|
||||||
|
<h1>Co-developpement - exemple</h1>
|
||||||
|
<p class="subtitle">10 personnes fictives. Clique sur un mode pour voir les matchs.</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="codev-tabs">
|
||||||
|
<button :class="{ active: tab === 'carto' }" @click="tab = 'carto'" type="button">Carto</button>
|
||||||
|
<button :class="{ active: tab === 'annuaire' }" @click="tab = 'annuaire'" type="button">Annuaire</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="tab === 'carto'">
|
||||||
|
<ClientOnly>
|
||||||
|
<CodevGraph
|
||||||
|
:fiches="fiches"
|
||||||
|
:matches="matches"
|
||||||
|
:mode="mode"
|
||||||
|
/>
|
||||||
|
<template #fallback>
|
||||||
|
<div class="graph-fallback">Chargement du graphe...</div>
|
||||||
|
</template>
|
||||||
|
</ClientOnly>
|
||||||
|
|
||||||
|
<!-- Bandeau info mode actif -->
|
||||||
|
<div v-if="mode !== 'none'" class="mode-banner">
|
||||||
|
<span>
|
||||||
|
Mode {{ MODE_LABELS[mode] }} actif -
|
||||||
|
{{ matches.length }} connexion{{ matches.length !== 1 ? 's' : '' }} trouvee{{ matches.length !== 1 ? 's' : '' }}.
|
||||||
|
</span>
|
||||||
|
<button class="banner-clear" @click="setMode('none')" type="button">Effacer</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Boutons matching -->
|
||||||
|
<div class="matching-controls">
|
||||||
|
<button
|
||||||
|
:class="{ active: mode === 'solution' }"
|
||||||
|
style="--mode-color: #22c55e"
|
||||||
|
@click="setMode('solution')"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Solution
|
||||||
|
<span class="hint">besoin - offre</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
:class="{ active: mode === 'alliance' }"
|
||||||
|
style="--mode-color: #f97316"
|
||||||
|
@click="setMode('alliance')"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Alliance
|
||||||
|
<span class="hint">besoins partages</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="mode !== 'none'"
|
||||||
|
class="reset"
|
||||||
|
@click="setMode('none')"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Effacer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="tab === 'annuaire'" class="annuaire-wrap">
|
||||||
|
<div class="annuaire-scroll">
|
||||||
|
<table class="annuaire-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="col-nom">Prénom</th>
|
||||||
|
<th class="col-besoin">Besoin</th>
|
||||||
|
<th class="col-offre">Ce que j'offre</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="f in fiches" :key="f.id" class="annuaire-row">
|
||||||
|
<td class="col-nom">{{ f.nom }}</td>
|
||||||
|
<td class="col-besoin">{{ f.besoin }}</td>
|
||||||
|
<td class="col-offre">{{ f.offre }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { CodevFiche, CodevMatch } from '~/types/codev'
|
||||||
|
import { computeMatches } from '~/utils/codev/matching'
|
||||||
|
|
||||||
|
const tab = ref<'carto' | 'annuaire'>('carto')
|
||||||
|
|
||||||
|
// 10 fiches sans hashtags — textes enrichis pour que scoreDirect discrimine bien les 3 modes :
|
||||||
|
//
|
||||||
|
// Solution (scoreDirect besoinA vs offreB) :
|
||||||
|
// Sami(besoin vendre formation) -> Ines(offre vente formations) ✓
|
||||||
|
// Nael(besoin site web formation) -> Sami(offre developpement web) ✓
|
||||||
|
// Eva(besoin coaching vente) -> Ines(offre vente formations) ✓
|
||||||
|
// Tom(besoin tiers-lieu) -> Zoe(offre facilitation tiers-lieux) ✓
|
||||||
|
//
|
||||||
|
// Alliance (besoins similaires) :
|
||||||
|
// Lea + Maya (coaching, lancer, offre) ✓
|
||||||
|
// Tom + Zoe (tiers-lieu, co-creer) ✓
|
||||||
|
// Sami + Kenji (vendre, formations) ✓
|
||||||
|
//
|
||||||
|
// Surprise (offres similaires) :
|
||||||
|
// Lea + Zoe (facilitation, groupes) ✓
|
||||||
|
// Tom + Roman (architecture) ✓
|
||||||
|
// Ines + Nael (marketing, formations) ✓
|
||||||
|
|
||||||
|
const FICHES_DEMO: CodevFiche[] = [
|
||||||
|
{
|
||||||
|
id: 1, nom: 'Lea',
|
||||||
|
besoin: 'Structurer et lancer mon offre de coaching professionnel cet automne',
|
||||||
|
offre: 'Facilitation de groupes et animation de cercles de parole',
|
||||||
|
hashtags: [],
|
||||||
|
created_at: '2026-05-08T10:00:00Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2, nom: 'Sami',
|
||||||
|
besoin: 'Vendre ma formation en ligne et attirer mes premiers clients',
|
||||||
|
offre: 'Developpement web sur mesure, creation de sites et applications',
|
||||||
|
hashtags: [],
|
||||||
|
created_at: '2026-05-08T10:01:00Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3, nom: 'Ines',
|
||||||
|
besoin: 'Ameliorer la facilitation de mes ateliers collaboratifs',
|
||||||
|
offre: 'Vente de formations en ligne et marketing pour formateurs',
|
||||||
|
hashtags: [],
|
||||||
|
created_at: '2026-05-08T10:02:00Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 4, nom: 'Tom',
|
||||||
|
besoin: 'Trouver des associes pour co-creer un tiers-lieu rural',
|
||||||
|
offre: 'Architecture bioclimatique et eco-construction pour tiers-lieux',
|
||||||
|
hashtags: [],
|
||||||
|
created_at: '2026-05-08T10:03:00Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 5, nom: 'Maya',
|
||||||
|
besoin: 'Creer et lancer mon offre de coaching en transition professionnelle',
|
||||||
|
offre: 'Accompagnement coaching de carriere et transitions professionnelles',
|
||||||
|
hashtags: [],
|
||||||
|
created_at: '2026-05-08T10:04:00Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 6, nom: 'Kenji',
|
||||||
|
besoin: 'Apprendre a vendre mes formations sans pression commerciale',
|
||||||
|
offre: 'Photographie professionnelle et direction artistique editoriale',
|
||||||
|
hashtags: [],
|
||||||
|
created_at: '2026-05-08T10:05:00Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 7, nom: 'Zoe',
|
||||||
|
besoin: 'Co-creer un tiers-lieu avec des porteurs de projet alignes',
|
||||||
|
offre: 'Facilitation de collectifs et animation en intelligence collective',
|
||||||
|
hashtags: [],
|
||||||
|
created_at: '2026-05-08T10:06:00Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 8, nom: 'Nael',
|
||||||
|
besoin: 'Creer un site web pour presenter et vendre ma formation',
|
||||||
|
offre: 'Strategie marketing digital et lancement de produits en ligne',
|
||||||
|
hashtags: [],
|
||||||
|
created_at: '2026-05-08T10:07:00Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 9, nom: 'Eva',
|
||||||
|
besoin: 'Lancer mon coaching avec une page de vente qui convertit',
|
||||||
|
offre: 'Ecriture longue forme, articles de fond et tribunes editoriales',
|
||||||
|
hashtags: [],
|
||||||
|
created_at: '2026-05-08T10:08:00Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 10, nom: 'Roman',
|
||||||
|
besoin: 'Ecrire de meilleurs articles pour mon blog et ma newsletter',
|
||||||
|
offre: 'Architecture technique et plans pour renovation energetique',
|
||||||
|
hashtags: [],
|
||||||
|
created_at: '2026-05-08T10:09:00Z',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const fiches = ref(FICHES_DEMO)
|
||||||
|
const matches = ref<CodevMatch[]>([])
|
||||||
|
const mode = ref<'none' | 'solution' | 'alliance' | 'surprise'>('none')
|
||||||
|
|
||||||
|
const MODE_LABELS: Record<string, string> = {
|
||||||
|
solution: 'Solution',
|
||||||
|
alliance: 'Alliance',
|
||||||
|
surprise: 'Surprise',
|
||||||
|
}
|
||||||
|
|
||||||
|
useHead({ title: 'Demo - Co-developpement' })
|
||||||
|
|
||||||
|
function setMode(newMode: typeof mode.value) {
|
||||||
|
mode.value = newMode
|
||||||
|
if (newMode === 'none') {
|
||||||
|
matches.value = []
|
||||||
|
} else {
|
||||||
|
matches.value = computeMatches(fiches.value, newMode, 0.12)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.codev-demo {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: var(--nav-bg, #fafafa);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 1.25rem 1rem 2rem;
|
||||||
|
gap: 1rem;
|
||||||
|
max-width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── En-tete ── */
|
||||||
|
|
||||||
|
.demo-header {
|
||||||
|
text-align: center;
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.demo-badge {
|
||||||
|
display: inline-block;
|
||||||
|
background: #f97316;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.demo-header h1 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--nav-text, #1a1a2e);
|
||||||
|
margin: 0 0 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--nav-text-muted, #6b7280);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Fallback ── */
|
||||||
|
|
||||||
|
.graph-fallback {
|
||||||
|
width: 100%;
|
||||||
|
height: 70vh;
|
||||||
|
min-height: 320px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--nav-text-muted, #6b7280);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
background: var(--nav-bg-alt, #f3f4f6);
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Tabs ── */
|
||||||
|
|
||||||
|
.codev-tabs { display: flex; gap: 4px; background: #f3f4f6; border-radius: 10px; padding: 4px; }
|
||||||
|
.codev-tabs button { flex: 1; padding: 8px 4px; border: none; border-radius: 7px; background: transparent; font-size: 0.875rem; font-weight: 500; cursor: pointer; color: #6b7280; transition: all 0.15s; }
|
||||||
|
.codev-tabs button.active { background: white; color: #1a1a2e; font-weight: 600; box-shadow: 0 1px 3px rgba(0,0,0,0.08); }
|
||||||
|
|
||||||
|
/* ── Annuaire ── */
|
||||||
|
|
||||||
|
.annuaire-wrap { display: flex; flex-direction: column; gap: 8px; flex: 1; }
|
||||||
|
.annuaire-scroll { overflow-x: auto; -webkit-overflow-scrolling: touch; border: 1px solid #e5e7eb; border-radius: 10px; }
|
||||||
|
.annuaire-table { width: 100%; border-collapse: collapse; min-width: 480px; }
|
||||||
|
.annuaire-table thead tr { background: #f9fafb; border-bottom: 2px solid #e5e7eb; }
|
||||||
|
.annuaire-table th { padding: 10px 14px; text-align: left; font-size: 0.75rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; color: #6b7280; white-space: nowrap; }
|
||||||
|
.annuaire-table td { padding: 12px 14px; font-size: 0.875rem; color: #374151; vertical-align: top; border-bottom: 1px solid #f3f4f6; line-height: 1.5; }
|
||||||
|
.annuaire-row { transition: background 0.12s; }
|
||||||
|
.annuaire-row:hover { background: #f9fafb; }
|
||||||
|
.annuaire-row:last-child td { border-bottom: none; }
|
||||||
|
.col-nom { position: sticky; left: 0; z-index: 2; background: #ffffff; font-weight: 600; color: #1a1a2e !important; white-space: nowrap; min-width: 80px; border-right: 2px solid #e5e7eb; box-shadow: 2px 0 6px rgba(0,0,0,0.06); }
|
||||||
|
.annuaire-row:hover .col-nom { background: #f9fafb; }
|
||||||
|
thead tr .col-nom { background: #f9fafb; z-index: 3; }
|
||||||
|
.col-besoin { min-width: 200px; max-width: 260px; }
|
||||||
|
.col-offre { min-width: 200px; max-width: 260px; }
|
||||||
|
|
||||||
|
/* ── Bandeau mode actif ── */
|
||||||
|
|
||||||
|
.mode-banner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.5rem 0.875rem;
|
||||||
|
background: #f0fdf4;
|
||||||
|
border: 1px solid #bbf7d0;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #166534;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner-clear {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #166534;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid #166534;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.2rem 0.6rem;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner-clear:hover {
|
||||||
|
background: #166534;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Boutons matching ── */
|
||||||
|
|
||||||
|
.matching-controls {
|
||||||
|
position: sticky;
|
||||||
|
bottom: 0;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
background: rgba(255, 255, 255, 0.95);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
border-top: 1px solid #e5e7eb;
|
||||||
|
margin: 0 -1rem -2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.matching-controls button {
|
||||||
|
flex: 1;
|
||||||
|
padding: 12px 8px;
|
||||||
|
border: 1px solid #d0d4dc;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: white;
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.matching-controls button .hint {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #6b7280;
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.matching-controls button.active {
|
||||||
|
background: var(--mode-color, #1B4436);
|
||||||
|
color: white;
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.matching-controls button.active .hint {
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.matching-controls button.reset {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: #f3f4f6;
|
||||||
|
border-color: #d0d4dc;
|
||||||
|
color: #374151;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.matching-controls button.reset:hover {
|
||||||
|
background: #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 500px) {
|
||||||
|
.matching-controls {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
margin: 0 -0.75rem -1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.matching-controls button.reset {
|
||||||
|
grid-column: span 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Mobile ── */
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.codev-demo {
|
||||||
|
padding: 1rem 0.75rem 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.demo-header h1 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
415
pages/codev/fiche.vue
Normal file
415
pages/codev/fiche.vue
Normal file
@@ -0,0 +1,415 @@
|
|||||||
|
<template>
|
||||||
|
<div class="fiche-page">
|
||||||
|
<div class="fiche-inner">
|
||||||
|
|
||||||
|
<!-- En-tête -->
|
||||||
|
<div class="fiche-header">
|
||||||
|
<NuxtLink to="/codev/carto" class="back-link">← Retour à la carte</NuxtLink>
|
||||||
|
<h1>{{ isEdit ? 'Modifier ma fiche' : 'Ma fiche' }}</h1>
|
||||||
|
<p class="fiche-lead">3 lignes pour te présenter. Le reste se passe entre nous.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Formulaire -->
|
||||||
|
<form class="fiche-form" @submit.prevent="submit" novalidate>
|
||||||
|
|
||||||
|
<!-- Nom -->
|
||||||
|
<div class="field-group">
|
||||||
|
<label for="nom">
|
||||||
|
Prénom <span class="required">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="nom"
|
||||||
|
v-model="form.nom"
|
||||||
|
type="text"
|
||||||
|
placeholder="Ex : Camille"
|
||||||
|
required
|
||||||
|
minlength="2"
|
||||||
|
maxlength="50"
|
||||||
|
:disabled="loading"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Besoin -->
|
||||||
|
<div class="field-group">
|
||||||
|
<div class="label-row">
|
||||||
|
<label for="besoin">
|
||||||
|
Mon besoin actuel <span class="required">*</span>
|
||||||
|
</label>
|
||||||
|
<button type="button" class="tooltip-trigger" @click="toggleTip('besoin')" aria-label="C'est quoi un besoin ?">?</button>
|
||||||
|
</div>
|
||||||
|
<details v-if="activeTip === 'besoin'" class="tooltip-block" open>
|
||||||
|
<summary class="sr-only">Aide</summary>
|
||||||
|
<p>Un besoin, c'est ce qui te manque pour avancer. Ca peut etre concret (un coup de main sur un dossier) ou plus large (clarifier ou tu vas). Pas grave si c'est flou - la rencontre IRL aide a le preciser.</p>
|
||||||
|
</details>
|
||||||
|
<textarea
|
||||||
|
id="besoin"
|
||||||
|
v-model="form.besoin"
|
||||||
|
rows="3"
|
||||||
|
placeholder="Ex : J'ai besoin d'aide pour structurer mon offre de prestation"
|
||||||
|
required
|
||||||
|
minlength="5"
|
||||||
|
maxlength="300"
|
||||||
|
:disabled="loading"
|
||||||
|
/>
|
||||||
|
<span class="char-count" :class="{ 'char-warn': form.besoin.length > 260 }">
|
||||||
|
{{ form.besoin.length }}/300
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Offre -->
|
||||||
|
<div class="field-group">
|
||||||
|
<div class="label-row">
|
||||||
|
<label for="offre">
|
||||||
|
Ce que j'offre a la communaute <span class="required">*</span>
|
||||||
|
</label>
|
||||||
|
<button type="button" class="tooltip-trigger" @click="toggleTip('offre')" aria-label="C'est quoi une offre ?">?</button>
|
||||||
|
</div>
|
||||||
|
<details v-if="activeTip === 'offre'" class="tooltip-block" open>
|
||||||
|
<summary class="sr-only">Aide</summary>
|
||||||
|
<p>Une offre, c'est une competence, une experience ou une qualite que tu peux partager. Ce que les autres viennent chercher chez toi naturellement.</p>
|
||||||
|
</details>
|
||||||
|
<textarea
|
||||||
|
id="offre"
|
||||||
|
v-model="form.offre"
|
||||||
|
rows="3"
|
||||||
|
placeholder="Ex : Je peux partager mon expérience en facilitation de groupe"
|
||||||
|
required
|
||||||
|
minlength="5"
|
||||||
|
maxlength="300"
|
||||||
|
:disabled="loading"
|
||||||
|
/>
|
||||||
|
<span class="char-count" :class="{ 'char-warn': form.offre.length > 260 }">
|
||||||
|
{{ form.offre.length }}/300
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Hashtags -->
|
||||||
|
<div class="field-group">
|
||||||
|
<label for="hashtags">
|
||||||
|
Mots-clés
|
||||||
|
<span class="label-hint">(optionnel, 3 max, séparés par des virgules)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="hashtags"
|
||||||
|
v-model="form.hashtagsRaw"
|
||||||
|
type="text"
|
||||||
|
placeholder="Ex : business, écriture, écologie"
|
||||||
|
maxlength="120"
|
||||||
|
:disabled="loading"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Erreur serveur -->
|
||||||
|
<div v-if="error" class="server-error" role="alert">
|
||||||
|
{{ error }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bouton -->
|
||||||
|
<button type="submit" class="submit-btn" :disabled="loading">
|
||||||
|
{{ isEdit ? (loading ? 'Modification...' : 'Enregistrer les modifications') : (loading ? 'Envoi en cours...' : 'Ajouter ma fiche') }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<NuxtLink to="/codev/carto" class="skip-link">
|
||||||
|
Voir la carte sans créer de fiche →
|
||||||
|
</NuxtLink>
|
||||||
|
|
||||||
|
</form>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const route = useRoute()
|
||||||
|
const editId = computed(() => route.query.id ? Number(route.query.id) : null)
|
||||||
|
const isEdit = computed(() => editId.value !== null)
|
||||||
|
|
||||||
|
const form = ref({ nom: '', besoin: '', offre: '', hashtagsRaw: '' })
|
||||||
|
const error = ref('')
|
||||||
|
const loading = ref(false)
|
||||||
|
const activeTip = ref<'besoin' | 'offre' | null>(null)
|
||||||
|
|
||||||
|
useHead({ title: computed(() => isEdit.value ? 'Modifier ma fiche — Co-développement' : 'Ma fiche — Co-développement') })
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
if (!isEdit.value) return
|
||||||
|
try {
|
||||||
|
const fiche = await $fetch<any>(`/api/codev/fiches/${editId.value}`)
|
||||||
|
form.value.nom = fiche.nom
|
||||||
|
form.value.besoin = fiche.besoin
|
||||||
|
form.value.offre = fiche.offre
|
||||||
|
form.value.hashtagsRaw = fiche.hashtags.join(', ')
|
||||||
|
} catch {
|
||||||
|
error.value = 'Impossible de charger la fiche, elle a peut-etre ete supprimee.'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function toggleTip(field: 'besoin' | 'offre') {
|
||||||
|
activeTip.value = activeTip.value === field ? null : field
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
error.value = ''
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const hashtags = form.value.hashtagsRaw
|
||||||
|
.split(',')
|
||||||
|
.map((h) => h.trim().toLowerCase().replace(/^#/, ''))
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(0, 3)
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
nom: form.value.nom,
|
||||||
|
besoin: form.value.besoin,
|
||||||
|
offre: form.value.offre,
|
||||||
|
hashtags,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isEdit.value) {
|
||||||
|
await $fetch(`/api/codev/fiches/${editId.value}`, { method: 'PATCH', body: payload })
|
||||||
|
} else {
|
||||||
|
await $fetch('/api/codev/fiches', { method: 'POST', body: payload })
|
||||||
|
}
|
||||||
|
await navigateTo('/codev/carto')
|
||||||
|
} catch (e: any) {
|
||||||
|
error.value = e?.data?.message || e?.statusMessage || 'Erreur, reessaie'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* ── Layout ── */
|
||||||
|
|
||||||
|
.fiche-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: var(--nav-bg, #fafafa);
|
||||||
|
padding: 1.5rem 1rem 4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fiche-inner {
|
||||||
|
max-width: 480px;
|
||||||
|
margin: 0 auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── En-tête ── */
|
||||||
|
|
||||||
|
.back-link {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--nav-text-muted, #6b7280);
|
||||||
|
text-decoration: none;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-link:hover {
|
||||||
|
color: var(--nav-primary-solid, #1B4436);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fiche-header h1 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--nav-text, #1a1a2e);
|
||||||
|
margin: 0 0 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fiche-lead {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--nav-text-muted, #6b7280);
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Formulaire ── */
|
||||||
|
|
||||||
|
.fiche-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Champ ── */
|
||||||
|
|
||||||
|
.field-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-group label {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--nav-text, #1a1a2e);
|
||||||
|
}
|
||||||
|
|
||||||
|
.label-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label-hint {
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--nav-text-muted, #6b7280);
|
||||||
|
margin-left: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.required {
|
||||||
|
color: #c0392b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-group input[type="text"],
|
||||||
|
.field-group input[type="password"],
|
||||||
|
.field-group textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem 0.875rem;
|
||||||
|
border: 1px solid var(--border-color, #d0d4dc);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: var(--nav-text, #1a1a2e);
|
||||||
|
background: var(--nav-surface, #ffffff);
|
||||||
|
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, #1B4436);
|
||||||
|
box-shadow: 0 0 0 2px rgba(27, 68, 54, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-group input:disabled,
|
||||||
|
.field-group textarea:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-group textarea {
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.char-count {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--nav-text-muted, #6b7280);
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.char-warn {
|
||||||
|
color: #e67e22;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Tooltip ── */
|
||||||
|
|
||||||
|
.tooltip-trigger {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
background: var(--nav-surface, #ffffff);
|
||||||
|
border: 1px solid var(--border-color, #d0d4dc);
|
||||||
|
border-radius: 50%;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--nav-text-muted, #6b7280);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
line-height: 1;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: border-color 0.15s, color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-trigger:hover {
|
||||||
|
border-color: var(--nav-primary-solid, #1B4436);
|
||||||
|
color: var(--nav-primary-solid, #1B4436);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-block {
|
||||||
|
background: var(--nav-surface, #ffffff);
|
||||||
|
border: 1px solid var(--border-color, #d0d4dc);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.75rem 0.875rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--nav-text-muted, #6b7280);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-block p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sr-only {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Erreur serveur ── */
|
||||||
|
|
||||||
|
.server-error {
|
||||||
|
padding: 0.75rem 0.875rem;
|
||||||
|
background: #fdf0ee;
|
||||||
|
border: 1px solid #e74c3c;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #c0392b;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Bouton ── */
|
||||||
|
|
||||||
|
.submit-btn {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.875rem 1rem;
|
||||||
|
background: var(--nav-primary-solid, #1B4436);
|
||||||
|
color: #ffffff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-btn:hover:not(:disabled) {
|
||||||
|
opacity: 0.88;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-btn:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skip-link {
|
||||||
|
display: block;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.825rem;
|
||||||
|
color: var(--nav-text-muted, #9ca3af);
|
||||||
|
text-decoration: none;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
.skip-link:hover { color: var(--nav-text, #1a1a2e); }
|
||||||
|
|
||||||
|
/* ── Responsive ── */
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.fiche-page {
|
||||||
|
padding: 1.25rem 1rem 3rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
217
pages/codev/index.vue
Normal file
217
pages/codev/index.vue
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
<template>
|
||||||
|
<div class="lock-page">
|
||||||
|
<div class="lock-inner">
|
||||||
|
|
||||||
|
<div class="lock-header">
|
||||||
|
<h1>Co-développement</h1>
|
||||||
|
<p class="lock-subtitle">Entraide entre pairs</p>
|
||||||
|
<p class="lock-intro">Cet espace est un cercle. Pour entrer, il y a un mot.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form class="lock-form" @submit.prevent="submit" novalidate>
|
||||||
|
|
||||||
|
<div class="field-group">
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
v-model="password"
|
||||||
|
type="password"
|
||||||
|
placeholder="Mot de passe"
|
||||||
|
autocomplete="current-password"
|
||||||
|
required
|
||||||
|
:disabled="loading"
|
||||||
|
class="lock-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="error" class="lock-error" role="alert">
|
||||||
|
{{ error }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="lock-btn" :disabled="loading || !password">
|
||||||
|
{{ loading ? 'Vérification...' : 'Entrer' }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="lock-footer">
|
||||||
|
<NuxtLink to="/codev/demo" class="demo-link">Voir l'exemple →</NuxtLink>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const password = ref('')
|
||||||
|
const error = ref('')
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
useHead({ title: 'Co-développement — Entraide entre pairs' })
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
error.value = ''
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
await $fetch('/api/codev/auth', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { password: password.value },
|
||||||
|
})
|
||||||
|
await navigateTo('/codev/fiche')
|
||||||
|
} catch (e: any) {
|
||||||
|
error.value = e?.statusMessage || 'Mauvais mot de passe'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* ── Layout ── */
|
||||||
|
|
||||||
|
.lock-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: var(--nav-bg, #fafafa);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 1.5rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lock-inner {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── En-tête ── */
|
||||||
|
|
||||||
|
.lock-header {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lock-header h1 {
|
||||||
|
font-size: 1.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--nav-text, #1a1a2e);
|
||||||
|
margin: 0 0 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lock-subtitle {
|
||||||
|
font-size: 1rem;
|
||||||
|
color: var(--nav-text-muted, #6b7280);
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lock-intro {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--nav-text-muted, #6b7280);
|
||||||
|
line-height: 1.5;
|
||||||
|
margin: 0;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Formulaire ── */
|
||||||
|
|
||||||
|
.lock-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lock-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.875rem 1rem;
|
||||||
|
border: 1px solid var(--border-color, #d0d4dc);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: var(--nav-text, #1a1a2e);
|
||||||
|
background: var(--nav-surface, #ffffff);
|
||||||
|
font-family: inherit;
|
||||||
|
text-align: center;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
transition: border-color 0.15s, box-shadow 0.15s;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lock-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--nav-primary-solid, #1B4436);
|
||||||
|
box-shadow: 0 0 0 2px rgba(27, 68, 54, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lock-input:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Erreur ── */
|
||||||
|
|
||||||
|
.lock-error {
|
||||||
|
padding: 0.625rem 0.875rem;
|
||||||
|
background: #fdf0ee;
|
||||||
|
border: 1px solid #e74c3c;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #c0392b;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Bouton ── */
|
||||||
|
|
||||||
|
.lock-btn {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.875rem 1rem;
|
||||||
|
background: var(--nav-primary-solid, #1B4436);
|
||||||
|
color: #ffffff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lock-btn:hover:not(:disabled) {
|
||||||
|
opacity: 0.88;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lock-btn:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Pied de page ── */
|
||||||
|
|
||||||
|
.lock-footer {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.demo-link {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--nav-text-muted, #6b7280);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.demo-link:hover {
|
||||||
|
color: var(--nav-primary-solid, #1B4436);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Responsive ── */
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.lock-page {
|
||||||
|
padding: 1.25rem 1rem 2.5rem;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding-top: 3rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
94
pages/codev/qr.vue
Normal file
94
pages/codev/qr.vue
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
<template>
|
||||||
|
<div class="qr-page">
|
||||||
|
<div class="qr-card">
|
||||||
|
<h1>Co-développement</h1>
|
||||||
|
<p class="qr-subtitle">Scanne pour rejoindre la session</p>
|
||||||
|
|
||||||
|
<img
|
||||||
|
:src="`https://api.qrserver.com/v1/create-qr-code/?size=280x280&data=${encodeURIComponent(APP_URL)}&bgcolor=ffffff&color=1B4436&margin=2`"
|
||||||
|
alt="QR code aep.trans-former.fr/codev"
|
||||||
|
class="qr-img"
|
||||||
|
width="280"
|
||||||
|
height="280"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<p class="qr-url">{{ APP_URL }}</p>
|
||||||
|
<p class="qr-password">Mot de passe : <strong>merci</strong></p>
|
||||||
|
|
||||||
|
<a :href="`https://api.qrserver.com/v1/create-qr-code/?size=600x600&data=${encodeURIComponent(APP_URL)}&bgcolor=ffffff&color=1B4436&margin=2`"
|
||||||
|
download="codev-qr.png"
|
||||||
|
class="qr-download"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
Télécharger le QR code
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const APP_URL = 'https://aep.trans-former.fr/codev'
|
||||||
|
useHead({ title: 'QR Code — Co-développement' })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.qr-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: var(--nav-bg, #fafafa);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 2rem 1rem;
|
||||||
|
}
|
||||||
|
.qr-card {
|
||||||
|
background: white;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 2rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
max-width: 360px;
|
||||||
|
width: 100%;
|
||||||
|
box-shadow: 0 4px 24px rgba(0,0,0,0.08);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.qr-card h1 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1a1a2e;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.qr-subtitle {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #6b7280;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.qr-img {
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 2px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
.qr-url {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #9ca3af;
|
||||||
|
margin: 0;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
.qr-password {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: #374151;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.qr-download {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 10px 20px;
|
||||||
|
background: #1B4436;
|
||||||
|
color: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
.qr-download:hover { opacity: 0.88; }
|
||||||
|
</style>
|
||||||
138
pages/index.vue
138
pages/index.vue
@@ -40,10 +40,28 @@
|
|||||||
Mode dev — données seed
|
Mode dev — données seed
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ── VUE DESKTOP : Métropole pleine largeur + DOM-TOM row en bas ── -->
|
<!-- ── VUE DESKTOP : Onglets Métropole / Outre-mer ── -->
|
||||||
<div class="hidden lg:flex lg:flex-1 lg:flex-col lg:overflow-hidden">
|
<div class="hidden lg:flex lg:flex-1 lg:flex-col lg:overflow-hidden">
|
||||||
<!-- Carte Métropole — pleine largeur -->
|
<!-- Barre onglets desktop -->
|
||||||
<div class="flex flex-col flex-1 overflow-hidden">
|
<div class="shrink-0 flex" style="background: var(--nav-surface); border-bottom: 1px solid var(--nav-bg-alt);">
|
||||||
|
<button
|
||||||
|
class="px-5 py-2 text-sm font-medium transition-colors"
|
||||||
|
:style="desktopMapView === 'metropole'
|
||||||
|
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
|
||||||
|
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
|
||||||
|
@click="desktopMapView = 'metropole'"
|
||||||
|
>Métropolitain</button>
|
||||||
|
<button
|
||||||
|
class="px-5 py-2 text-sm font-medium transition-colors"
|
||||||
|
:style="desktopMapView === 'outremer'
|
||||||
|
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
|
||||||
|
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
|
||||||
|
@click="desktopMapView = 'outremer'"
|
||||||
|
>Outre-mer</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Carte Métropole desktop -->
|
||||||
|
<div v-show="desktopMapView === 'metropole'" class="flex-1 flex flex-col overflow-hidden">
|
||||||
<div class="relative flex-1" style="min-height: 200px;">
|
<div class="relative flex-1" style="min-height: 200px;">
|
||||||
<ClientOnly>
|
<ClientOnly>
|
||||||
<NavMap
|
<NavMap
|
||||||
@@ -53,38 +71,28 @@
|
|||||||
@select-org="onSelectOrg"
|
@select-org="onSelectOrg"
|
||||||
/>
|
/>
|
||||||
<template #fallback>
|
<template #fallback>
|
||||||
<div
|
<div class="w-full h-full flex items-center justify-center" style="background: var(--nav-bg-alt); color: var(--nav-text-muted);">Chargement de la carte…</div>
|
||||||
class="w-full h-full flex items-center justify-center"
|
|
||||||
style="background: var(--nav-bg-alt); color: var(--nav-text-muted);"
|
|
||||||
>
|
|
||||||
Chargement de la carte…
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
</ClientOnly>
|
</ClientOnly>
|
||||||
</div>
|
</div>
|
||||||
<ChatbotPlaceholder @highlightOrgs="onHighlightOrgs" />
|
<ChatbotPlaceholder @highlightOrgs="onHighlightOrgs" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Bandeau DOM-TOM — row horizontale pleine largeur, hauteur fixe -->
|
<!-- Carte Outre-mer desktop -->
|
||||||
<div
|
<div v-show="desktopMapView === 'outremer'" class="flex-1 flex flex-col overflow-hidden">
|
||||||
class="shrink-0"
|
<div class="flex-1 overflow-y-auto">
|
||||||
style="height: 140px; border-top: 1px solid var(--nav-bg-alt);"
|
<ClientOnly>
|
||||||
>
|
<OutremerMap
|
||||||
<ClientOnly>
|
:orgs="outremerOrgs"
|
||||||
<OutremerMap
|
:selectedId="selectedId"
|
||||||
:orgs="outremerOrgs"
|
@select-org="onSelectOrg"
|
||||||
:selectedId="selectedId"
|
/>
|
||||||
@select-org="onSelectOrg"
|
<template #fallback>
|
||||||
/>
|
<div class="flex items-center justify-center h-full text-sm" style="color: var(--nav-text-muted);">Chargement…</div>
|
||||||
<template #fallback>
|
</template>
|
||||||
<div
|
</ClientOnly>
|
||||||
class="flex items-center justify-center h-full text-sm"
|
</div>
|
||||||
style="color: var(--nav-text-muted);"
|
<ChatbotPlaceholder @highlightOrgs="onHighlightOrgs" />
|
||||||
>
|
|
||||||
Chargement…
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</ClientOnly>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -193,10 +201,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Filtres FONCTION — chips flex-wrap -->
|
<!-- Filtres FONCTION — chips flex-wrap + toggle collapse -->
|
||||||
<div class="mt-2">
|
<div class="mt-2">
|
||||||
<span class="text-xs font-bold uppercase tracking-wide block mb-1" style="color: var(--nav-text-muted);">FONCTION</span>
|
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 4px;">
|
||||||
<div class="flex flex-wrap gap-1">
|
<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
|
<span
|
||||||
v-for="fn in FONCTIONS"
|
v-for="fn in FONCTIONS"
|
||||||
:key="fn"
|
:key="fn"
|
||||||
@@ -304,6 +321,26 @@
|
|||||||
@highlightOrgs="onHighlightOrgs"
|
@highlightOrgs="onHighlightOrgs"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!-- ═══════════════════════════════════════ POP-UP MISSION ENTRAIDE -->
|
||||||
|
<button
|
||||||
|
class="mission-info-btn"
|
||||||
|
type="button"
|
||||||
|
@click="missionOpen = true"
|
||||||
|
aria-label="À propos de cette carte d'entraide"
|
||||||
|
title="À propos de cette carte"
|
||||||
|
>
|
||||||
|
<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">
|
||||||
|
<circle cx="12" cy="12" r="10"/>
|
||||||
|
<line x1="12" y1="16" x2="12" y2="12"/>
|
||||||
|
<line x1="12" y1="8" x2="12.01" y2="8"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<MissionPopup
|
||||||
|
:modelValue="missionOpen"
|
||||||
|
@update:modelValue="missionOpen = $event"
|
||||||
|
/>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -330,11 +367,22 @@ const territoireMode = ref<string>(
|
|||||||
(route.query.mode as string) === 'outremer' ? 'outremer' : 'metropole'
|
(route.query.mode as string) === 'outremer' ? 'outremer' : 'metropole'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const desktopMapView = ref<'metropole' | 'outremer'>('metropole')
|
||||||
const selectedId = ref<number | null>(null)
|
const selectedId = ref<number | null>(null)
|
||||||
const chatbotOpen = ref(false)
|
const chatbotOpen = ref(false)
|
||||||
const ficheModalOpen = ref(false)
|
const ficheModalOpen = ref(false)
|
||||||
const ficheModalId = ref<number | null>(null)
|
const ficheModalId = ref<number | null>(null)
|
||||||
const mobileMapView = ref<'metropole' | 'outremer'>('metropole')
|
const mobileMapView = ref<'metropole' | 'outremer'>('metropole')
|
||||||
|
const missionOpen = ref(false)
|
||||||
|
const mobileFonctionsOpen = ref(false)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
try {
|
||||||
|
if (!localStorage.getItem('aep_mission_seen')) {
|
||||||
|
missionOpen.value = true
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
})
|
||||||
// Surlignage temporaire (5 sec) suite à une réponse chatbot
|
// Surlignage temporaire (5 sec) suite à une réponse chatbot
|
||||||
// → sélectionne le premier ID recommandé sur la carte, puis remet à null
|
// → sélectionne le premier ID recommandé sur la carte, puis remet à null
|
||||||
let highlightTimer: ReturnType<typeof setTimeout> | null = null
|
let highlightTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
@@ -566,3 +614,29 @@ function fonctionsList(org: Org): string[] {
|
|||||||
|
|
||||||
useHead({ title: 'AEP — Cartographie de l\'écologie politique architecturale' })
|
useHead({ title: 'AEP — Cartographie de l\'écologie politique architecturale' })
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.mission-info-btn {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 24px;
|
||||||
|
left: 16px;
|
||||||
|
z-index: 1000;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: none;
|
||||||
|
background: var(--nav-surface);
|
||||||
|
color: var(--nav-text-muted);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-shadow: 0 2px 12px rgba(26,34,56,0.18);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.15s, transform 0.1s;
|
||||||
|
}
|
||||||
|
.mission-info-btn:hover { opacity: 0.85; transform: translateY(-1px); color: var(--nav-text); }
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.mission-info-btn { bottom: 16px; left: 340px; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
239
pages/manifeste.vue
Normal file
239
pages/manifeste.vue
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
<template>
|
||||||
|
<div class="manifeste-page">
|
||||||
|
<div class="manifeste-inner">
|
||||||
|
|
||||||
|
<NuxtLink to="/" class="back-link">← Retour à la carte</NuxtLink>
|
||||||
|
|
||||||
|
<h1 class="manifeste-title">Manifeste — Architecture d'Écologie Politique</h1>
|
||||||
|
|
||||||
|
<p class="lede">
|
||||||
|
<em>Un quart des architectes vivent sous le seuil de pauvreté. La moitié de nos heures, non facturées. Nos cotisations, parmi les plus lourdes des professions réglementées. Et le secteur du bâtiment, à lui seul, pèse 34 % des émissions mondiales de gaz à effet de serre.</em>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Quelque chose s'est rompu — pas dans nos vies, dans les cadres qui les contiennent.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Notre profession ne traverse pas une simple crise. Elle reflète l'effondrement d'un monde qui confond performance et destruction, signature et silence, expertise et soumission.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<hr />
|
||||||
|
|
||||||
|
<h2>Ce que nous voyons.</h2>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
À l'échelle du métier, une profession structurellement sous l'eau, qui absorbe les tensions d'un système extractiviste — et porte la responsabilité quand d'autres captent la valeur.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
À l'échelle des corps, une culture qui rend l'exploitation désirable : métier-passion, modèle starchitecte, isolement libéral, moteur critique délégitimant. Nous tenons. Nous payons.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
À l'échelle du monde, l'effondrement écologique et social qui avance, pendant que notre voix s'efface du débat public. Notre silence le sert.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<hr />
|
||||||
|
|
||||||
|
<h2>Ce que nous refusons.</h2>
|
||||||
|
|
||||||
|
<p class="refus">
|
||||||
|
Nous ne signerons plus pour des projets qui détruisent.<br />
|
||||||
|
Nous n'isolerons plus celles et ceux qui doutent.<br />
|
||||||
|
Nous ne porterons plus seul·es ce qui doit se penser, se faire — et se soigner — ensemble.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<hr />
|
||||||
|
|
||||||
|
<p class="pivot">
|
||||||
|
<strong>Et pourtant, quelque chose tient.</strong>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p class="pivot-suite">
|
||||||
|
Pas l'espoir naïf, ni la promesse héroïque. Quelque chose de plus humble : la fatigue commune reconnue, et l'envie qui revient de ne plus économiser sa vie.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<hr />
|
||||||
|
|
||||||
|
<h2>Ce que nous tentons.</h2>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<em>Partager.</em> Nos parcours, nos doutes, nos bifurcations. Se former les un·es les autres. Se tendre la main. Documenter ce qui marche, ce qui rate. Le personnel devient politique quand il se met en commun.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<em>Construire.</em> L'infrastructure collective qui nous a manqué. Cartes d'entraide, communs documentés, gouvernance horizontale, financement transparent, infra souveraine. <strong>Architecture d'Écologie Politique</strong> : un commun vivant, ouvert, biorégional, ancré.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<em>Pratiquer une médecine du corps social.</em> Diagnostiquer les infrastructures qui défaillent — l'éducation, la justice, la sécurité, l'énergie, la santé, le logement, l'agriculture. Proposer des reconfigurations situées, territoire par territoire. Reprendre le pouvoir par la base. Écrire, lentement, un nouveau contrat social.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<em>Commencer par les marges.</em> Là où le corps social souffre le plus, là où il est le plus prêt à changer. Ne pas décider à la place — faire émerger. Transparence totale, sur le process et sur l'argent. Tendresse militante : la lucidité sans le mépris, l'engagement sans la dureté.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<hr />
|
||||||
|
|
||||||
|
<h2>Architectes, allié·es, habitant·es.</h2>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Nous avons un travail à faire ensemble. Lentement, patiemment, par accumulation de petits gestes situés. Pas pour fuir — pour bifurquer.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p class="chute">
|
||||||
|
<em>Nos métiers sont des médecines. Reprenons-en le pouls — à mains nues, ensemble.</em>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<hr />
|
||||||
|
|
||||||
|
<p class="cta-wrap">
|
||||||
|
<a
|
||||||
|
href="https://www.trans-former.fr/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="btn-blog"
|
||||||
|
>
|
||||||
|
En lire plus — blog AEP →
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
useHead({
|
||||||
|
title: 'Manifeste — AEP',
|
||||||
|
meta: [
|
||||||
|
{ name: 'description', content: 'Manifeste d\'Architecture d\'Écologie Politique — un commun vivant pour bifurquer ensemble.' },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.manifeste-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: var(--nav-bg);
|
||||||
|
padding: 1.5rem 1rem 5rem;
|
||||||
|
overflow-x: hidden;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.manifeste-inner {
|
||||||
|
max-width: 680px;
|
||||||
|
margin: 0 auto;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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: 2rem;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-link:hover { opacity: 1; }
|
||||||
|
|
||||||
|
.manifeste-title {
|
||||||
|
font-size: 1.65rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--nav-text);
|
||||||
|
margin: 0 0 1.5rem;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lede {
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1.7;
|
||||||
|
color: var(--nav-text);
|
||||||
|
margin: 0 0 1.25rem;
|
||||||
|
border-left: 3px solid var(--nav-primary-solid);
|
||||||
|
padding-left: 1rem;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
font-size: 0.975rem;
|
||||||
|
line-height: 1.75;
|
||||||
|
color: var(--nav-text);
|
||||||
|
margin: 0 0 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 1.05rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--nav-text);
|
||||||
|
margin: 2rem 0 1rem;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
hr {
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid var(--nav-bg-alt);
|
||||||
|
margin: 2rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.refus {
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pivot {
|
||||||
|
font-size: 1.15rem;
|
||||||
|
text-align: center;
|
||||||
|
margin: 2rem 0 1rem;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pivot strong {
|
||||||
|
font-weight: 700;
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pivot-suite {
|
||||||
|
text-align: center;
|
||||||
|
font-style: italic;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chute {
|
||||||
|
font-size: 1.05rem;
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
color: var(--nav-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta-wrap {
|
||||||
|
text-align: center;
|
||||||
|
margin: 2rem 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-blog {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
background: var(--nav-primary);
|
||||||
|
color: var(--nav-text-on-primary);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-blog:hover { opacity: 0.85; }
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.manifeste-page { padding: 1rem 0.85rem 4rem; }
|
||||||
|
.manifeste-title { font-size: 1.4rem; }
|
||||||
|
.lede { font-size: 0.95rem; padding-left: 0.85rem; }
|
||||||
|
p { font-size: 0.95rem; }
|
||||||
|
.pivot { font-size: 1.05rem; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
329
pages/media.vue
Normal file
329
pages/media.vue
Normal file
@@ -0,0 +1,329 @@
|
|||||||
|
<template>
|
||||||
|
<div class="media-page" style="background: var(--nav-bg);">
|
||||||
|
|
||||||
|
<!-- 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' : '',
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<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
|
||||||
|
@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>
|
||||||
|
<button
|
||||||
|
v-if="layoutMode !== 'split'"
|
||||||
|
@click="setLayoutMode('split')"
|
||||||
|
class="toggle-btn toggle-btn-reset"
|
||||||
|
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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Slot chatbot inline -->
|
||||||
|
<div
|
||||||
|
class="chatbot-slot"
|
||||||
|
:class="[
|
||||||
|
layoutMode === 'split' ? 'chatbot-split' : '',
|
||||||
|
layoutMode === 'chatbot-full' ? 'chatbot-full-mode' : '',
|
||||||
|
layoutMode === 'carte-full' ? 'chatbot-hidden' : '',
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<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">
|
||||||
|
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'
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'media-layout-mode'
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
const corpusCount = computed(() => penseesData.value?.auteurs.length ?? 0)
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
penseesData.value = await $fetch<PenseesData>('/data/auteurs-pensees.json')
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Erreur chargement auteurs-pensees.json', e)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 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>
|
||||||
|
/* 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: flex-basis 0.3s ease, height 0.3s ease, opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.carte-split {
|
||||||
|
flex: 2 1 0;
|
||||||
|
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 var(--nav-bg-alt);
|
||||||
|
border-bottom: 1px solid var(--nav-bg-alt);
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-btn-reset {
|
||||||
|
margin-left: auto;
|
||||||
|
background: var(--nav-surface);
|
||||||
|
color: var(--nav-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-btn-reset:hover {
|
||||||
|
background: var(--nav-bg-alt);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Slot chatbot --- */
|
||||||
|
.chatbot-slot {
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
transition: flex-basis 0.3s ease, height 0.3s ease, opacity 0.2s ease;
|
||||||
|
border-top: 1px solid var(--nav-bg-alt);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatbot-split {
|
||||||
|
flex: 1 1 0;
|
||||||
|
min-height: 0;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatbot-full-mode {
|
||||||
|
flex: 1 1 100%;
|
||||||
|
min-height: 0;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatbot-hidden {
|
||||||
|
flex: 0 0 0;
|
||||||
|
height: 0;
|
||||||
|
opacity: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Responsive mobile (<768px) --- */
|
||||||
|
/* Stack vertical : carte 60vh + chatbot 40vh en mode split */
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.carte-split {
|
||||||
|
flex: 0 0 60vh;
|
||||||
|
height: 60vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatbot-split {
|
||||||
|
flex: 0 0 calc(40vh - 38px);
|
||||||
|
height: calc(40vh - 38px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-btn span,
|
||||||
|
.toggle-btn {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
padding: 3px 7px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="flex flex-col items-center justify-center h-full gap-6" style="background: var(--nav-bg);">
|
|
||||||
<div class="text-center max-w-md px-6">
|
|
||||||
<div
|
|
||||||
class="inline-flex items-center justify-center w-14 h-14 rounded-2xl mb-5"
|
|
||||||
style="background: var(--nav-bg-alt);"
|
|
||||||
>
|
|
||||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="color: var(--nav-text-muted);">
|
|
||||||
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/>
|
|
||||||
<polyline points="3.27 6.96 12 12.01 20.73 6.96"/>
|
|
||||||
<line x1="12" y1="22.08" x2="12" y2="12"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<h1 class="text-2xl font-bold mb-3" style="color: var(--nav-text);">RAG — Retrieval Augmented Generation</h1>
|
|
||||||
<p class="text-sm leading-relaxed mb-6" style="color: var(--nav-text-muted);">
|
|
||||||
Une base de connaissances interrogeable par IA — textes, rapports, manifestes et ressources documentaires sur l'architecture d'écologie politique.
|
|
||||||
</p>
|
|
||||||
<p class="text-xs font-semibold uppercase tracking-widest mb-6" style="color: var(--nav-text-muted); opacity: 0.6;">
|
|
||||||
Bientôt disponible
|
|
||||||
</p>
|
|
||||||
<NuxtLink
|
|
||||||
to="/"
|
|
||||||
class="inline-flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-semibold transition-all hover:opacity-80"
|
|
||||||
style="background: var(--nav-primary); color: var(--nav-text-on-primary);"
|
|
||||||
>
|
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" aria-hidden="true">
|
|
||||||
<line x1="19" y1="12" x2="5" y2="12"/>
|
|
||||||
<polyline points="12 19 5 12 12 5"/>
|
|
||||||
</svg>
|
|
||||||
Retour à l'écosystème
|
|
||||||
</NuxtLink>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
useHead({ title: 'RAG — AEP (bientôt disponible)' })
|
|
||||||
</script>
|
|
||||||
1016
pages/trouver-du-taf.vue
Normal file
1016
pages/trouver-du-taf.vue
Normal file
File diff suppressed because it is too large
Load Diff
491
public/data/auteurs-pensees.json
Normal file
491
public/data/auteurs-pensees.json
Normal file
@@ -0,0 +1,491 @@
|
|||||||
|
{
|
||||||
|
"meta": {
|
||||||
|
"version": "2.0",
|
||||||
|
"source": "FRACAS Bonpote V2 oct 2024 + LightRAG corpus 11/05/2026",
|
||||||
|
"corpus_ingere": 141,
|
||||||
|
"auteurs_count": 28,
|
||||||
|
"livres_count": 64,
|
||||||
|
"ecoles_count": 12,
|
||||||
|
"note_doublons_en_fr": "3 livres avec version EN aussi indexee dans le RAG pour cross-language queries : carson-mer-autour-de-nous-fr/EN, graeber-wengrow-aurore-fr/EN, saito-capital-anthropocene/EN. JSON conserve la version FR.",
|
||||||
|
"updated": "2026-05-11"
|
||||||
|
},
|
||||||
|
"ecoles": [
|
||||||
|
{
|
||||||
|
"id": "ecosocialisme",
|
||||||
|
"label": "Écosocialisme",
|
||||||
|
"description": "Synthèse du marxisme et de l'écologie. Articule la critique du capitalisme et la crise écologique comme deux faces d'un même système.",
|
||||||
|
"color": "#c0392b",
|
||||||
|
"x_hint": 0.55,
|
||||||
|
"y_hint": 0.28
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "marxismes-ecologiques",
|
||||||
|
"label": "Marxismes écologiques",
|
||||||
|
"description": "Relecture écologique des écrits de Marx et de ses continuateurs contemporains. Le Capital comme critique du métabolisme homme-nature. Décroissance communiste.",
|
||||||
|
"color": "#8e44ad",
|
||||||
|
"x_hint": 0.65,
|
||||||
|
"y_hint": 0.2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "eco-anarchisme",
|
||||||
|
"label": "Écologies libertaires",
|
||||||
|
"description": "Filiation des traditions du socialisme ouvrier anglais et de l'anarchisme. Les dominations de l'homme sur l'homme, sur la femme et sur la nature ne peuvent être prises séparément. Éco-communautés, institutions autogérées, démocratie radicale, municipalisme libertaire.",
|
||||||
|
"color": "#2d6a4f",
|
||||||
|
"x_hint": 0.25,
|
||||||
|
"y_hint": 0.3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "decroissance",
|
||||||
|
"label": "Décroissance",
|
||||||
|
"description": "Critique radicale de la croissance économique comme horizon. Pour une réduction volontaire de la production et de la consommation.",
|
||||||
|
"color": "#e67e22",
|
||||||
|
"x_hint": 0.38,
|
||||||
|
"y_hint": 0.42
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ecofeminismes",
|
||||||
|
"label": "Écoféminismes",
|
||||||
|
"description": "Connexions entre la domination des femmes et la domination de la nature. Féminisme de la subsistance, critique du développement, commons.",
|
||||||
|
"color": "#e07a5f",
|
||||||
|
"x_hint": 0.48,
|
||||||
|
"y_hint": 0.68
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "technocritique",
|
||||||
|
"label": "Écologies anti-industrielles",
|
||||||
|
"description": "Rejet du productivisme et de l'hyper-mécanisation issus de l'ère industrielle. Approche technocritique : critique du gigantisme productif et de l'État, refus de l'idéologie du Progrès. Considérer la technique comme un système avec ses logiques propres.",
|
||||||
|
"color": "#7f8c8d",
|
||||||
|
"x_hint": 0.2,
|
||||||
|
"y_hint": 0.48
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ecologies-decoloniales",
|
||||||
|
"label": "Écologies décoloniales",
|
||||||
|
"description": "Articulation des luttes écologiques et des luttes anticoloniales. Critique de l'extractivisme comme continuation du colonialisme.",
|
||||||
|
"color": "#b5451b",
|
||||||
|
"x_hint": 0.3,
|
||||||
|
"y_hint": 0.72
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ethiques-environnementales",
|
||||||
|
"label": "Éthiques environnementales",
|
||||||
|
"description": "Philosophies de la nature : deep ecology, écocentrisme, droits des non-humains. Valeur intrinsèque du vivant.",
|
||||||
|
"color": "#2c7873",
|
||||||
|
"x_hint": 0.72,
|
||||||
|
"y_hint": 0.72
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "pensees-vivant",
|
||||||
|
"label": "Pensées du vivant",
|
||||||
|
"description": "Anthropologie et ontologies de la nature. Dépasser le dualisme nature/culture. Sympoïèse, multi-espèces, éthologie politique.",
|
||||||
|
"color": "#6b8e6e",
|
||||||
|
"x_hint": 0.62,
|
||||||
|
"y_hint": 0.58
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "collapsologie",
|
||||||
|
"label": "Collapsologie",
|
||||||
|
"description": "Étude interdisciplinaire de l'effondrement de la civilisation industrielle et des voies de résilience. Articule sciences du vivant, géopolitique et psychologie de la transition.",
|
||||||
|
"color": "#34495e",
|
||||||
|
"x_hint": 0.42,
|
||||||
|
"y_hint": 0.22
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "capitalisme-vert",
|
||||||
|
"label": "Capitalisme vert",
|
||||||
|
"description": "Théoriciens du capitalisme qui intègrent la dimension environnementale aux échanges marchands (taxes, compensation, technologies vertes). Certains accélèrent la dynamique capitaliste, voulant contrôler le Système-Terre sans nuire aux intérêts de la classe possédante. Famille critiquée par toutes les autres.",
|
||||||
|
"color": "#6c8a6d",
|
||||||
|
"x_hint": 0.85,
|
||||||
|
"y_hint": 0.5,
|
||||||
|
"corpus_status": "non_ingere",
|
||||||
|
"note_editoriale": "Famille intégrée pour fidélité à la carte FRACAS Bonpote. Pas d'auteurs ingérés dans le RAG ATIS (critique éditoriale assumée)."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ecofascismes",
|
||||||
|
"label": "Écofascismes",
|
||||||
|
"description": "Émergés à bas bruit depuis les années 1980, fragmentés. En Europe : éco-différentialisme, séparation des « races »/civilisations adaptées à leur environnement. Aux USA : néo-malthusianisme, xénophobie, apologie de la wilderness, logiques survivalistes. Famille critiquée par toutes les autres.",
|
||||||
|
"color": "#5d4037",
|
||||||
|
"x_hint": 0.92,
|
||||||
|
"y_hint": 0.85,
|
||||||
|
"corpus_status": "non_ingere",
|
||||||
|
"note_editoriale": "Famille intégrée pour fidélité à la carte FRACAS Bonpote. Pas d'auteurs ingérés dans le RAG ATIS (critique éditoriale assumée)."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"auteurs": [
|
||||||
|
{
|
||||||
|
"id": "murray-bookchin",
|
||||||
|
"nom": "Murray Bookchin",
|
||||||
|
"dates": "1921-2006",
|
||||||
|
"ecoles": ["eco-anarchisme"],
|
||||||
|
"ecole_principale": "eco-anarchisme",
|
||||||
|
"livres_rag": [
|
||||||
|
{ "slug": "bookchin-ecologie-liberte", "titre": "L'Écologie de la liberté", "annee": 1982, "couches": ["fond", "structure"] },
|
||||||
|
{ "slug": "bookchin-post-scarcity", "titre": "Post-Scarcity Anarchism", "annee": 1971, "couches": ["fond", "structure"] },
|
||||||
|
{ "slug": "bookchin-urbanization-citizenship", "titre": "The Rise of Urbanization and the Decline of Citizenship", "annee": 1987, "couches": ["fond", "structure"] }
|
||||||
|
],
|
||||||
|
"theses_cles": ["Municipalisme libertaire", "Écologie sociale", "Hiérarchie comme origine de la domination nature"],
|
||||||
|
"bio_courte": "Théoricien américain de l'écologie sociale et du municipalisme libertaire. A développé le concept d'écologie sociale articulant domination sociale et destruction de la nature."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "pierre-kropotkine",
|
||||||
|
"nom": "Pierre Kropotkine",
|
||||||
|
"dates": "1842-1921",
|
||||||
|
"ecoles": ["eco-anarchisme"],
|
||||||
|
"ecole_principale": "eco-anarchisme",
|
||||||
|
"livres_rag": [
|
||||||
|
{ "slug": "kropotkine-entraide", "titre": "L'Entraide, un facteur de l'évolution", "annee": 1902, "couches": ["fond", "structure"] },
|
||||||
|
{ "slug": "kropotkine-conquete-pain", "titre": "La Conquête du pain", "annee": 1892, "couches": ["fond", "structure"] },
|
||||||
|
{ "slug": "kropotkine-champs-usines", "titre": "Champs, usines et ateliers", "annee": 1898, "couches": ["fond", "structure"] }
|
||||||
|
],
|
||||||
|
"theses_cles": ["Entraide vs sélection naturelle darwiniste", "Fédéralisme anarchiste", "Géographie critique et décentralisation industrielle"],
|
||||||
|
"bio_courte": "Géographe et révolutionnaire russe. Son oeuvre centrale démontre que l'entraide, et non la compétition, est le moteur principal de l'évolution."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "elisee-reclus",
|
||||||
|
"nom": "Élisée Reclus",
|
||||||
|
"dates": "1830-1905",
|
||||||
|
"ecoles": ["eco-anarchisme"],
|
||||||
|
"ecole_principale": "eco-anarchisme",
|
||||||
|
"livres_rag": [
|
||||||
|
{ "slug": "reclus-homme-terre", "titre": "L'Homme et la Terre", "annee": 1905, "couches": ["fond", "structure"] },
|
||||||
|
{ "slug": "reclus-evolution-revolution", "titre": "L'Évolution, la révolution et l'idéal anarchique", "annee": 1898, "couches": ["fond", "structure"] },
|
||||||
|
{ "slug": "reclus-histoire-ruisseau", "titre": "Histoire d'un ruisseau", "annee": 1869, "couches": ["fond", "structure"] }
|
||||||
|
],
|
||||||
|
"theses_cles": ["Géographie sociale anarchiste", "Homme comme nature prenant conscience d'elle-même", "Antimilitarisme et internationalisme"],
|
||||||
|
"bio_courte": "Géographe anarchiste français, auteur de la Nouvelle Géographie universelle. Précurseur de l'écologie politique et de la géographie humaine critique."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "david-graeber",
|
||||||
|
"nom": "David Graeber",
|
||||||
|
"dates": "1961-2020",
|
||||||
|
"ecoles": ["eco-anarchisme"],
|
||||||
|
"ecole_principale": "eco-anarchisme",
|
||||||
|
"livres_rag": [
|
||||||
|
{ "slug": "graeber-bullshit-jobs", "titre": "Bullshit Jobs", "annee": 2018, "couches": ["fond", "structure"] },
|
||||||
|
{ "slug": "graeber-wengrow-aurore", "titre": "Au commencement était... Une nouvelle histoire de l'humanité", "annee": 2021, "couches": ["fond", "structure"] }
|
||||||
|
],
|
||||||
|
"theses_cles": ["Travail sans valeur comme instrument de domination", "Anthropologie anarchiste", "Contre le récit d'une évolution linéaire de l'humanité"],
|
||||||
|
"bio_courte": "Anthropologue américain, figure du mouvement Occupy. Ses travaux déconstruisent les mythes fondateurs du capitalisme et proposent une anthropologie radicalement alternative.",
|
||||||
|
"note_rag": "graeber-wengrow-aurore-fr aussi indexe pour cross-language queries"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "karl-marx",
|
||||||
|
"nom": "Karl Marx",
|
||||||
|
"dates": "1818-1883",
|
||||||
|
"ecoles": ["marxismes-ecologiques", "ecosocialisme"],
|
||||||
|
"ecole_principale": "marxismes-ecologiques",
|
||||||
|
"livres_rag": [
|
||||||
|
{ "slug": "marx-manuscrits-1844", "titre": "Manuscrits économico-philosophiques de 1844", "annee": 1844, "couches": ["fond", "structure"] },
|
||||||
|
{ "slug": "marx-capital-livre1", "titre": "Le Capital, Livre I", "annee": 1867, "couches": ["fond", "structure"] },
|
||||||
|
{ "slug": "marx-grundrisse", "titre": "Grundrisse", "annee": 1857, "couches": ["fond", "structure"] }
|
||||||
|
],
|
||||||
|
"theses_cles": ["Métabolisme entre travail humain et nature", "Aliénation naturelle", "Accumulation primitive et rupture métabolique"],
|
||||||
|
"bio_courte": "Pensée-racine des marxismes écologiques. Les Grundrisse et le Capital contiennent une critique écologique du capitalisme que le 20e siècle a largement occultée."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "kohei-saito",
|
||||||
|
"nom": "Kohei Saito",
|
||||||
|
"dates": "1987-",
|
||||||
|
"ecoles": ["marxismes-ecologiques"],
|
||||||
|
"ecole_principale": "marxismes-ecologiques",
|
||||||
|
"livres_rag": [
|
||||||
|
{ "slug": "saito-marx-anthropocene", "titre": "Marx dans l'Anthropocène", "annee": 2016, "couches": ["fond", "structure"] },
|
||||||
|
{ "slug": "saito-decroissance-communisme", "titre": "La Décroissance communiste", "annee": 2020, "couches": ["fond", "structure"] },
|
||||||
|
{ "slug": "saito-capital-anthropocene", "titre": "Le Capital dans l'Anthropocène", "annee": 2020, "couches": ["fond", "structure"] }
|
||||||
|
],
|
||||||
|
"theses_cles": ["Marx et l'écologie dans les cahiers tardifs", "Métabolisme social et rupture métabolique", "Décroissance communiste comme horizon"],
|
||||||
|
"bio_courte": "Philosophe japonais, auteur d'une relecture écologiste des cahiers tardifs de Marx. Défend une décroissance communiste comme seule réponse cohérente à la crise écologique.",
|
||||||
|
"note_rag": "saito-capital-anthropocene-en aussi indexe pour cross-language queries"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "michael-lowy",
|
||||||
|
"nom": "Michael Löwy",
|
||||||
|
"dates": "1938-",
|
||||||
|
"ecoles": ["ecosocialisme"],
|
||||||
|
"ecole_principale": "ecosocialisme",
|
||||||
|
"livres_rag": [
|
||||||
|
{ "slug": "lowy-ecosocialisme", "titre": "Écosocialisme", "annee": 2011, "couches": ["fond", "structure"] }
|
||||||
|
],
|
||||||
|
"theses_cles": ["Romantisme révolutionnaire", "Anticapitalisme écologique", "Walter Benjamin et l'écologie"],
|
||||||
|
"bio_courte": "Sociologue franco-brésilien, figure centrale de l'écosocialisme. Articule marxisme hétérodoxe et critique de la modernité industrielle."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "andreas-malm",
|
||||||
|
"nom": "Andreas Malm",
|
||||||
|
"dates": "1977-",
|
||||||
|
"ecoles": ["ecosocialisme"],
|
||||||
|
"ecole_principale": "ecosocialisme",
|
||||||
|
"livres_rag": [
|
||||||
|
{ "slug": "malm-fossil-capital", "titre": "Fossil Capital", "annee": 2016, "couches": ["fond", "structure"] },
|
||||||
|
{ "slug": "malm-saboter-pipeline", "titre": "Comment saboter un pipeline ?", "annee": 2020, "couches": ["fond", "structure"] },
|
||||||
|
{ "slug": "malm-corona-climat", "titre": "Corona, Climate, Chronic Emergency", "annee": 2020, "couches": ["fond", "structure"] }
|
||||||
|
],
|
||||||
|
"theses_cles": ["Capitalisme fossile comme choix historique, non technologique", "Sabotage stratégique", "Urgence climatique et action directe"],
|
||||||
|
"bio_courte": "Professeur d'écologie humaine à Lund. Théoricien du capital fossile et défenseur d'une écologie de guerre pour répondre à l'urgence climatique."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "naomi-klein",
|
||||||
|
"nom": "Naomi Klein",
|
||||||
|
"dates": "1970-",
|
||||||
|
"ecoles": ["ecosocialisme"],
|
||||||
|
"ecole_principale": "ecosocialisme",
|
||||||
|
"livres_rag": [
|
||||||
|
{ "slug": "klein-strategie-choc", "titre": "La Stratégie du choc", "annee": 2007, "couches": ["fond", "structure"] },
|
||||||
|
{ "slug": "klein-tout-peut-changer", "titre": "Tout peut changer", "annee": 2014, "couches": ["fond", "structure"] },
|
||||||
|
{ "slug": "klein-feu", "titre": "Le Feu qui nous attend", "annee": 2019, "couches": ["fond", "structure"] }
|
||||||
|
],
|
||||||
|
"theses_cles": ["Capitalisme du désastre", "Crise climatique comme opportunité de transformation radicale", "Gestion du choc comme tactique néolibérale"],
|
||||||
|
"bio_courte": "Journaliste et activiste canadienne, une des voix les plus influentes du mouvement clima-justice. Articule critique du capitalisme et urgence écologique."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "andre-gorz",
|
||||||
|
"nom": "André Gorz",
|
||||||
|
"dates": "1923-2007",
|
||||||
|
"ecoles": ["ecosocialisme", "decroissance", "technocritique"],
|
||||||
|
"ecole_principale": "ecosocialisme",
|
||||||
|
"livres_rag": [
|
||||||
|
{ "slug": "gorz-capitalisme-socialisme-ecologie", "titre": "Capitalisme, Socialisme, Écologie", "annee": 1991, "couches": ["fond", "structure"] },
|
||||||
|
{ "slug": "gorz-immateriel", "titre": "L'Immatériel", "annee": 2003, "couches": ["fond", "structure"] },
|
||||||
|
{ "slug": "gorz-utopie-ou-mort", "titre": "Utopie ou mort", "annee": 1975, "couches": ["fond", "structure"] }
|
||||||
|
],
|
||||||
|
"theses_cles": ["Sortie du capitalisme par la réduction du temps de travail", "Économie de suffisance", "Immatériel comme nouvelle aliénation"],
|
||||||
|
"bio_courte": "Philosophe austro-français, pionnier de l'écosocialisme et de la critique du travail. Relie marxisme, existentialisme et écologie dans une pensée de la libération."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "serge-latouche",
|
||||||
|
"nom": "Serge Latouche",
|
||||||
|
"dates": "1940-",
|
||||||
|
"ecoles": ["decroissance"],
|
||||||
|
"ecole_principale": "decroissance",
|
||||||
|
"livres_rag": [
|
||||||
|
{ "slug": "latouche-abondance-frugale", "titre": "Bon pour la casse : les déraisons de l'obsolescence programmée", "annee": 2012, "couches": ["fond", "structure"] },
|
||||||
|
{ "slug": "latouche-petit-traite-decroissance", "titre": "Petit traité de la décroissance sereine", "annee": 2007, "couches": ["fond", "structure"] },
|
||||||
|
{ "slug": "latouche-reenchanter-monde", "titre": "Pour un biorégionalisme en bonne intelligence avec les autres", "annee": 2019, "couches": ["fond", "structure"] }
|
||||||
|
],
|
||||||
|
"theses_cles": ["Sereine décroissance", "Critique du développement et de l'occidentalisation", "Société frugale abondante"],
|
||||||
|
"bio_courte": "Économiste hétérodoxe franco-algérien, principal théoricien de la décroissance en France. Critique radical de l'économie du développement et de l'impérialisme culturel."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "nicholas-georgescu-roegen",
|
||||||
|
"nom": "Nicholas Georgescu-Roegen",
|
||||||
|
"dates": "1906-1994",
|
||||||
|
"ecoles": ["decroissance"],
|
||||||
|
"ecole_principale": "decroissance",
|
||||||
|
"livres_rag": [
|
||||||
|
{ "slug": "georgescu-decroissance", "titre": "Demain la décroissance", "annee": 1979, "couches": ["fond", "structure"] }
|
||||||
|
],
|
||||||
|
"theses_cles": ["Entropie et économie", "Impossibilité thermodynamique de la croissance infinie", "Bioéconomie"],
|
||||||
|
"bio_courte": "Mathématicien et économiste roumain, père fondateur de la bioéconomie. Démontre que la croissance économique est irréversiblement limitée par les lois de la thermodynamique."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "donella-meadows",
|
||||||
|
"nom": "Dennis et Donella Meadows",
|
||||||
|
"dates": "1941-2001 / 1942-",
|
||||||
|
"ecoles": ["decroissance"],
|
||||||
|
"ecole_principale": "decroissance",
|
||||||
|
"livres_rag": [
|
||||||
|
{ "slug": "meadows-halte-croissance", "titre": "Halte à la croissance ?", "annee": 1972, "couches": ["fond", "structure"] },
|
||||||
|
{ "slug": "meadows-thinking-systems", "titre": "Thinking in Systems", "annee": 2008, "couches": ["fond", "structure"] }
|
||||||
|
],
|
||||||
|
"theses_cles": ["Limites planétaires", "Modèles systémiques de l'overshoot", "Pensée systémique comme outil de transformation"],
|
||||||
|
"bio_courte": "Le rapport Meadows (1972) est le premier modèle systémique démontrant l'impossibilité d'une croissance infinie dans un monde fini."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "pablo-servigne",
|
||||||
|
"nom": "Pablo Servigne",
|
||||||
|
"dates": "1978-",
|
||||||
|
"ecoles": ["collapsologie"],
|
||||||
|
"ecole_principale": "collapsologie",
|
||||||
|
"livres_rag": [
|
||||||
|
{ "slug": "servigne-effondrer", "titre": "Comment tout peut s'effondrer", "annee": 2015, "couches": ["fond", "structure"] },
|
||||||
|
{ "slug": "servigne-autre-fin-du-monde", "titre": "Une autre fin du monde est possible", "annee": 2018, "couches": ["fond", "structure"] },
|
||||||
|
{ "slug": "servigne-entraide-autre-loi", "titre": "L'Entraide, l'autre loi de la jungle", "annee": 2017, "couches": ["fond", "structure"] }
|
||||||
|
],
|
||||||
|
"theses_cles": ["Collapsologie", "Conditions systémiques de l'effondrement industriel", "Transition post-collapse et résilience collective"],
|
||||||
|
"bio_courte": "Ingénieur agronome belge, cofondateur de la collapsologie. Explore les conditions d'un effondrement de la civilisation industrielle et les voies de résilience collective."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "francoise-deaubonne",
|
||||||
|
"nom": "Françoise d'Eaubonne",
|
||||||
|
"dates": "1920-2005",
|
||||||
|
"ecoles": ["ecofeminismes"],
|
||||||
|
"ecole_principale": "ecofeminismes",
|
||||||
|
"livres_rag": [
|
||||||
|
{ "slug": "eaubonne-feminisme-mort", "titre": "Le Féminisme ou la mort", "annee": 1974, "couches": ["fond", "structure"] }
|
||||||
|
],
|
||||||
|
"theses_cles": ["Écoféminisme (terme inventé en 1974)", "Patriarcat et destruction de la nature comme même système", "Révolution féministe écologique"],
|
||||||
|
"bio_courte": "Féministe française, inventrice du terme écoféminisme en 1974. Lie patriarcat et destruction de l'environnement dans une même critique radicale."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "silvia-federici",
|
||||||
|
"nom": "Silvia Federici",
|
||||||
|
"dates": "1942-",
|
||||||
|
"ecoles": ["ecofeminismes"],
|
||||||
|
"ecole_principale": "ecofeminismes",
|
||||||
|
"livres_rag": [
|
||||||
|
{ "slug": "federici-caliban-sorciere", "titre": "Caliban et la sorcière", "annee": 2004, "couches": ["fond", "structure"] },
|
||||||
|
{ "slug": "federici-par-dela-peau", "titre": "Par-delà la peau", "annee": 2019, "couches": ["fond", "structure"] },
|
||||||
|
{ "slug": "federici-point-zero", "titre": "Le Point zéro de la révolution", "annee": 2012, "couches": ["fond", "structure"] }
|
||||||
|
],
|
||||||
|
"theses_cles": ["Accumulation primitive et corps des femmes", "Chasse aux sorcières comme contre-révolution", "Travail reproductif et commons"],
|
||||||
|
"bio_courte": "Philosophe italo-américaine, théoricienne du féminisme marxiste. Caliban et la sorcière relit l'accumulation primitive à travers la domination des femmes et la destruction des commons."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "vandana-shiva",
|
||||||
|
"nom": "Vandana Shiva",
|
||||||
|
"dates": "1952-",
|
||||||
|
"ecoles": ["ecofeminismes", "ecologies-decoloniales"],
|
||||||
|
"ecole_principale": "ecofeminismes",
|
||||||
|
"livres_rag": [
|
||||||
|
{ "slug": "shiva-staying-alive", "titre": "Staying Alive: Women, Ecology and Development", "annee": 1988, "couches": ["fond", "structure"] }
|
||||||
|
],
|
||||||
|
"theses_cles": ["Biopiraterie et souveraineté alimentaire", "Écoféminisme tiers-mondiste", "Développement comme destruction"],
|
||||||
|
"bio_courte": "Physicienne et militante indienne, figure mondiale de l'écoféminisme et de la souveraineté alimentaire. Cofondatrice de Navdanya, contre la biopiraterie des semences."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "fatima-ouassak",
|
||||||
|
"nom": "Fatima Ouassak",
|
||||||
|
"dates": "1978-",
|
||||||
|
"ecoles": ["ecofeminismes", "ecologies-decoloniales"],
|
||||||
|
"ecole_principale": "ecofeminismes",
|
||||||
|
"livres_rag": [
|
||||||
|
{ "slug": "ouassak-puissance-meres", "titre": "La Puissance des mères", "annee": 2020, "couches": ["fond", "structure"] }
|
||||||
|
],
|
||||||
|
"theses_cles": ["Féminisme populaire et de couleur", "Écologie de banlieue", "Puissance maternelle comme force politique"],
|
||||||
|
"bio_courte": "Politiste et militante franco-tunisienne. Articule luttes de classes populaires, antiracisme et écologie dans une perspective féministe ancrée dans les quartiers."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "malcolm-ferdinand",
|
||||||
|
"nom": "Malcom Ferdinand",
|
||||||
|
"dates": "1985-",
|
||||||
|
"ecoles": ["ecologies-decoloniales"],
|
||||||
|
"ecole_principale": "ecologies-decoloniales",
|
||||||
|
"livres_rag": [
|
||||||
|
{ "slug": "ferdinand-ecologie-decoloniale", "titre": "Une écologie décoloniale", "annee": 2019, "couches": ["fond", "structure"] },
|
||||||
|
{ "slug": "ferdinand-monde-en-commun", "titre": "Un monde en commun", "annee": 2022, "couches": ["fond", "structure"] }
|
||||||
|
],
|
||||||
|
"theses_cles": ["Double fracture coloniale et écologique", "Habiter le monde en commun", "Antillanité et écologie politique"],
|
||||||
|
"bio_courte": "Ingénieur et philosophe martiniquais. Son oeuvre articule colonialisme et destruction de l'environnement autour de la double fracture coloniale-écologique."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "jacques-ellul",
|
||||||
|
"nom": "Jacques Ellul",
|
||||||
|
"dates": "1912-1994",
|
||||||
|
"ecoles": ["technocritique"],
|
||||||
|
"ecole_principale": "technocritique",
|
||||||
|
"livres_rag": [
|
||||||
|
{ "slug": "ellul-technique-enjeu-siecle", "titre": "La Technique ou l'Enjeu du siècle", "annee": 1954, "couches": ["fond", "structure"] },
|
||||||
|
{ "slug": "ellul-systeme-technicien", "titre": "Le Système technicien", "annee": 1977, "couches": ["fond", "structure"] },
|
||||||
|
{ "slug": "ellul-bluff-technologique", "titre": "Le Bluff technologique", "annee": 1988, "couches": ["fond", "structure"] }
|
||||||
|
],
|
||||||
|
"theses_cles": ["Technique comme système autonome échappant au contrôle humain", "Efficacité comme valeur unique de la modernité", "Propagande et technosystème"],
|
||||||
|
"bio_courte": "Juriste, sociologue et théologien bordelais. Son oeuvre fondatrice analyse la Technique comme système autonome, père de la technocritique radicale française."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "bernard-charbonneau",
|
||||||
|
"nom": "Bernard Charbonneau",
|
||||||
|
"dates": "1910-1996",
|
||||||
|
"ecoles": ["technocritique"],
|
||||||
|
"ecole_principale": "technocritique",
|
||||||
|
"livres_rag": [
|
||||||
|
{ "slug": "charbonneau-jardin-babylone", "titre": "Le Jardin de Babylone", "annee": 1969, "couches": ["fond", "structure"] }
|
||||||
|
],
|
||||||
|
"theses_cles": ["Liberté contre organisation", "Critique de l'aménagement du territoire", "Personnalisme anarchisant et nature"],
|
||||||
|
"bio_courte": "Essayiste béarnais, compagnon d'Ellul. Pionnier méconnu de l'écologie politique française. Défend la liberté contre toute organisation — État, marché, technique."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rachel-carson",
|
||||||
|
"nom": "Rachel Carson",
|
||||||
|
"dates": "1907-1964",
|
||||||
|
"ecoles": ["ethiques-environnementales"],
|
||||||
|
"ecole_principale": "ethiques-environnementales",
|
||||||
|
"livres_rag": [
|
||||||
|
{ "slug": "carson-printemps-silencieux", "titre": "Printemps silencieux", "annee": 1962, "couches": ["fond", "structure"] },
|
||||||
|
{ "slug": "carson-mer-autour-de-nous", "titre": "The Sea Around Us", "annee": 1951, "couches": ["fond", "structure"] }
|
||||||
|
],
|
||||||
|
"theses_cles": ["Impact des pesticides sur les écosystèmes", "Naissance du mouvement environnementaliste moderne", "Responsabilité scientifique et démocratie"],
|
||||||
|
"bio_courte": "Marine biologist et autrice américaine. Printemps silencieux (1962) a lancé le mouvement environnementaliste moderne en dénonçant les pesticides.",
|
||||||
|
"note_rag": "carson-mer-autour-de-nous-fr aussi indexe pour cross-language queries"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "arne-naess",
|
||||||
|
"nom": "Arne Næss",
|
||||||
|
"dates": "1912-2009",
|
||||||
|
"ecoles": ["ethiques-environnementales"],
|
||||||
|
"ecole_principale": "ethiques-environnementales",
|
||||||
|
"livres_rag": [
|
||||||
|
{ "slug": "naess-ecology-of-wisdom", "titre": "Ecology of Wisdom", "annee": 2008, "couches": ["fond", "structure"] }
|
||||||
|
],
|
||||||
|
"theses_cles": ["Deep ecology vs écologie superficielle", "Égalité biosphérique", "Réalisation de Soi élargie au-delà du moi individuel"],
|
||||||
|
"bio_courte": "Philosophe norvégien, fondateur de la deep ecology. Défend une valeur intrinsèque de tous les êtres vivants, indépendamment de leur utilité pour les humains."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "philippe-descola",
|
||||||
|
"nom": "Philippe Descola",
|
||||||
|
"dates": "1949-",
|
||||||
|
"ecoles": ["pensees-vivant"],
|
||||||
|
"ecole_principale": "pensees-vivant",
|
||||||
|
"livres_rag": [
|
||||||
|
{ "slug": "descola-par-dela-nature-culture", "titre": "Par-delà nature et culture", "annee": 2005, "couches": ["fond", "structure"] },
|
||||||
|
{ "slug": "descola-composition-mondes", "titre": "La Composition des mondes", "annee": 2014, "couches": ["fond", "structure"] }
|
||||||
|
],
|
||||||
|
"theses_cles": ["Dualisme nature/culture comme exception occidentale", "4 ontologies (animisme, totémisme, analogisme, naturalisme)", "Cosmopolitiques et pluriversalité"],
|
||||||
|
"bio_courte": "Anthropologue et ethnologue français, successeur de Lévi-Strauss au Collège de France. Démontre que le dualisme nature/culture est une anomalie culturelle occidentale."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "vinciane-despret",
|
||||||
|
"nom": "Vinciane Despret",
|
||||||
|
"dates": "1959-",
|
||||||
|
"ecoles": ["pensees-vivant"],
|
||||||
|
"ecole_principale": "pensees-vivant",
|
||||||
|
"livres_rag": [
|
||||||
|
{ "slug": "despret-habiter-oiseau", "titre": "Habiter en oiseau", "annee": 2019, "couches": ["fond", "structure"] },
|
||||||
|
{ "slug": "despret-autobiographie-poulpe", "titre": "Autobiographie d'un poulpe", "annee": 2021, "couches": ["fond", "structure"] },
|
||||||
|
{ "slug": "despret-quand-loup-habitera", "titre": "Quand le loup habitera avec l'agneau", "annee": 2002, "couches": ["fond", "structure"] }
|
||||||
|
],
|
||||||
|
"theses_cles": ["Éthologie politique", "Faire bon ménage avec les non-humains", "Épistémologie du point de vue animal"],
|
||||||
|
"bio_courte": "Philosophe et éthologiste belge. Explore comment penser avec les animaux plutôt que sur eux, développant une éthologie politique de la cohabitation inter-espèces."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "baptiste-morizot",
|
||||||
|
"nom": "Baptiste Morizot",
|
||||||
|
"dates": "1983-",
|
||||||
|
"ecoles": ["pensees-vivant"],
|
||||||
|
"ecole_principale": "pensees-vivant",
|
||||||
|
"livres_rag": [
|
||||||
|
{ "slug": "morizot-sur-piste-animale", "titre": "Sur la piste animale", "annee": 2018, "couches": ["fond", "structure"] },
|
||||||
|
{ "slug": "morizot-manieres-etre-vivant", "titre": "Manières d'être vivant", "annee": 2020, "couches": ["fond", "structure"] },
|
||||||
|
{ "slug": "morizot-raviver-braises", "titre": "Raviver les braises du vivant", "annee": 2020, "couches": ["fond", "structure"] }
|
||||||
|
],
|
||||||
|
"theses_cles": ["Crise de la sensibilité au vivant", "Diplomatie sauvage", "Désensauvagement comme désorientation ontologique"],
|
||||||
|
"bio_courte": "Philosophe et pisteur français. Propose une diplomatie sauvage fondée sur l'attention au vivant. La crise écologique comme crise de la relation, avant d'être une crise de ressources."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "bruno-latour",
|
||||||
|
"nom": "Bruno Latour",
|
||||||
|
"dates": "1947-2022",
|
||||||
|
"ecoles": ["pensees-vivant"],
|
||||||
|
"ecole_principale": "pensees-vivant",
|
||||||
|
"livres_rag": [
|
||||||
|
{ "slug": "latour-jamais-ete-modernes", "titre": "Nous n'avons jamais été modernes", "annee": 1991, "couches": ["fond", "structure"] },
|
||||||
|
{ "slug": "latour-face-a-gaia", "titre": "Face à Gaïa", "annee": 2015, "couches": ["fond", "structure"] },
|
||||||
|
{ "slug": "latour-ou-atterrir", "titre": "Où atterrir ?", "annee": 2017, "couches": ["fond", "structure"] }
|
||||||
|
],
|
||||||
|
"theses_cles": ["Modernes n'ayant jamais séparé nature et société", "Gaïa comme entité politique", "Terrestres vs Hors-sol"],
|
||||||
|
"bio_courte": "Sociologue et philosophe français, fondateur de la théorie acteur-réseau. Son dernier travail tourne autour de Gaïa et de la question politique du Terrestre face au dérèglement."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "isabelle-stengers",
|
||||||
|
"nom": "Isabelle Stengers",
|
||||||
|
"dates": "1949-",
|
||||||
|
"ecoles": ["pensees-vivant"],
|
||||||
|
"ecole_principale": "pensees-vivant",
|
||||||
|
"livres_rag": [
|
||||||
|
{ "slug": "stengers-cosmopolitiques", "titre": "Cosmopolitiques", "annee": 1997, "couches": ["fond", "structure"] },
|
||||||
|
{ "slug": "stengers-reactiver-sens-commun", "titre": "Réactiver le sens commun", "annee": 2020, "couches": ["fond", "structure"] }
|
||||||
|
],
|
||||||
|
"theses_cles": ["Cosmopolitiques : faire droit aux pratiques non-scientifiques", "Capitalisme comme sorcellerie", "Sens commun contre la raison instrumentale"],
|
||||||
|
"bio_courte": "Philosophe des sciences belge. Déploie une pensée cosmopolitique qui fait droit à toutes les pratiques, scientifiques et non-scientifiques, face à la destruction capitaliste."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
809
public/data/plateformes-taff.json
Normal file
809
public/data/plateformes-taff.json
Normal file
@@ -0,0 +1,809 @@
|
|||||||
|
{
|
||||||
|
"meta": {
|
||||||
|
"version": "2026-05-06-T2",
|
||||||
|
"date_generation": "2026-05-06T18:30:00Z",
|
||||||
|
"total": 24,
|
||||||
|
"repartition": {
|
||||||
|
"recommande": 7,
|
||||||
|
"sous_reserve": 14,
|
||||||
|
"a_eviter": 3
|
||||||
|
},
|
||||||
|
"repartition_type": {
|
||||||
|
"b2c": 16,
|
||||||
|
"appel_offre_public": 8
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"plateformes": [
|
||||||
|
{
|
||||||
|
"id": "hemea",
|
||||||
|
"nom": "hemea",
|
||||||
|
"url": "https://www.hemea.com",
|
||||||
|
"type": "b2c-mise-en-relation",
|
||||||
|
"description": "## Présentation\nhemea (anciennement Travauxlib, rebaptisé en 2018) est une plateforme B-Corp certifiée depuis 2020, spécialisée rénovation et architecture. Elle gère 5 000+ projets depuis 2015 et dispose de 100+ experts dédiés. Modèle tiers de confiance avec séquestre des paiements.\n\n## Modèle économique\nCommission côté client de 5-10% (mission partielle) à 10-15% (mission complète) du montant HT. Les architectes intègrent le réseau comme prestataires coordonnés par hemea. La commission prélevée côté professionnel n\u0027est pas documentée — CGV introuvables sur le site (404).\n\n## Pour qui\nArchitectes et artisans cherchant un volume régulier de chantiers de rénovation, acceptant de travailler en sous-traitance coordonnée plutôt qu\u0027en relation directe client. Moins adapté aux indépendants souhaitant garder la main sur leur relation commerciale.\n\n## Points forts\nCertification B-Corp et positionnement RSE documenté. Séquestre des paiements protecteur pour les deux parties. Notoriété forte dans l\u0027écosystème rénovation français. Volume de projets significatif (5 000+ depuis 2015).\n\n## Points de vigilance\nLe modèle place l\u0027architecte en sous-traitant, pas en maître d\u0027œuvre autonome. Verbatims Trustpilot signalent une surfacturation liée à la double marge (hemea + artisan). En cas de litige, hemea se repositionne comme \u0027courtier\u0027, pas maître d\u0027œuvre — responsabilité diluée.",
|
||||||
|
"description_courte": "Courtier BTP B-Corp spécialisé rénovation, les architectes intègrent le réseau comme sous-traitants coordonnés. En cas de litige, hemea se repositionne comme simple courtier — responsabilité diluée.",
|
||||||
|
"scoring": {
|
||||||
|
"remuneration": "⚠️",
|
||||||
|
"transparence": "⚠️",
|
||||||
|
"pratiques": "⚠️",
|
||||||
|
"ecologie": "✅",
|
||||||
|
"matching": "⚠️",
|
||||||
|
"tag_global": "sous-reserve",
|
||||||
|
"justification_tag": "Modèle tiers de confiance avec commission 5-15% côté client et CGV introuvables — l\u0027architecte est sous-traitant coordonné, pas en relation directe. Opacité sur la rémunération côté professionnel et ambiguïté sur la responsabilité en cas de litige (Trustpilot 4.6/5, 976 avis, verbatims négatifs convergents sur ce point)."
|
||||||
|
},
|
||||||
|
"secteurs_servis": [
|
||||||
|
"renovation",
|
||||||
|
"construction-neuve",
|
||||||
|
"architecture-interieure",
|
||||||
|
"mar-conseil"
|
||||||
|
],
|
||||||
|
"zone_geo": "france-entiere",
|
||||||
|
"cout_entree": "commission",
|
||||||
|
"date_creation_fiche": "2026-05-06",
|
||||||
|
"date_derniere_maj": "2026-05-06",
|
||||||
|
"source_donnees": [
|
||||||
|
"https://r.jina.ai/https://www.hemea.com",
|
||||||
|
"https://fr.trustpilot.com/review/hemea.com"
|
||||||
|
],
|
||||||
|
"flag_validation_jules": false,
|
||||||
|
"commentaires": [
|
||||||
|
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "travaux-com",
|
||||||
|
"nom": "Travaux.com",
|
||||||
|
"url": "https://www.travaux.com",
|
||||||
|
"type": "b2c-mise-en-relation",
|
||||||
|
"description": "## Présentation\nPlateforme généraliste de mise en relation artisans-particuliers, parmi les plus anciennes en France. 63 207 artisans qualifiés référencés et 206 000+ avis clients. Dispose d\u0027une catégorie architecte (/architecte) mais reste dominée par les artisans BTP généralistes.\n\n## Modèle économique\nLead payant pour les professionnels : publication de projet gratuite pour le particulier, accès aux coordonnées payant pour le pro. Tarifs par lead non publiés, variables selon département et secteur. Upselling commercial vers des offres premium documenté.\n\n## Pour qui\nArtisans BTP généralistes en priorité. Pour les architectes, utilité limitée : visibilité réduite dans un catalogue majoritairement orienté artisanat. Potentiellement utile pour des missions rénovation simples en zone rurale avec peu d\u0027alternatives.\n\n## Points forts\nNotoriété grand public et volume de projets importants. Couverture nationale complète. Pas de commission prélevée sur les honoraires — tarification au lead uniquement. Marque reconnue depuis 20+ ans dans le secteur.\n\n## Points de vigilance\nDémarchage commercial agressif et upselling signalés par des professionnels inscrits. Qualité des leads variable. Note Trustpilot 3.9/5 sur 10 311 avis avec 16% de 1 étoile — révélateur de frustration chez les pros. Tarification opaque.",
|
||||||
|
"description_courte": "Plateforme généraliste leads BTP à notoriété grand public. Architectes noyés parmi les artisans, tarification opaque et démarchage commercial agressif signalé par des professionnels inscrits.",
|
||||||
|
"scoring": {
|
||||||
|
"remuneration": "⚠️",
|
||||||
|
"transparence": "⚠️",
|
||||||
|
"pratiques": "⚠️",
|
||||||
|
"ecologie": "❌",
|
||||||
|
"matching": "⚠️",
|
||||||
|
"tag_global": "sous-reserve",
|
||||||
|
"justification_tag": "Un ❌ sur l\u0027axe écologie (aucun positionnement ni pédagogie écologique sur une plateforme généraliste BTP) mais les 3 axes critiques restent en ⚠️. Upselling commercial agressif documenté côté pros (Trustpilot 3.9/5, 10 311 avis, 16% de 1 étoile)."
|
||||||
|
},
|
||||||
|
"secteurs_servis": [
|
||||||
|
"renovation",
|
||||||
|
"construction-neuve"
|
||||||
|
],
|
||||||
|
"zone_geo": "france-entiere",
|
||||||
|
"cout_entree": "lead-paye",
|
||||||
|
"date_creation_fiche": "2026-05-06",
|
||||||
|
"date_derniere_maj": "2026-05-06",
|
||||||
|
"source_donnees": [
|
||||||
|
"https://r.jina.ai/https://www.travaux.com/architecte",
|
||||||
|
"https://fr.trustpilot.com/review/travaux.com"
|
||||||
|
],
|
||||||
|
"flag_validation_jules": false,
|
||||||
|
"commentaires": [
|
||||||
|
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "habitatpresto",
|
||||||
|
"nom": "Habitatpresto",
|
||||||
|
"url": "https://www.habitatpresto.com",
|
||||||
|
"type": "b2c-mise-en-relation",
|
||||||
|
"description": "## Présentation\nPlateforme de mise en relation artisans-particuliers fondée en 2005 (21 ans d\u0027expertise). Interface de sélection par département et type de travaux. Équipe commerciale L-V 9h-18h. Couvre essentiellement l\u0027artisanat du bâtiment, avec une présence de profils architecture.\n\n## Modèle économique\nAbonnement mensuel fixe couvrant l\u0027accès illimité aux coordonnées clients. Tarif non publié — personnalisation téléphonique par région et catégorie. Frais de mise en service additionnels. La page /pro/tarifs est accessible mais n\u0027affiche aucun prix.\n\n## Pour qui\nArtisans du bâtiment cherchant des chantiers en volume. Pour les architectes indépendants, l\u0027utilité est très limitée : secteur principalement orienté artisanat, pas architecture de conception ou maîtrise d\u0027œuvre.\n\n## Points forts\nAbonnement à tarif fixe sans surprise par mission. Couverture nationale, présence dans tous les départements. Ancienneté du service (21 ans). Pas de commission par projet — modèle prévisible une fois le prix négocié.\n\n## Points de vigilance\nTarification entièrement opaque : aucun prix publié, devis uniquement par téléphone. Verbatim professionnel Trustpilot sévère : \u0027arnaque, 6 propositions reçues en 6 mois\u0027. Note 4.1/5 mais 22% de 1 étoile. Sortie d\u0027abonnement potentiellement difficile sans test de qualité préalable.",
|
||||||
|
"description_courte": "Abonnement artisans BTP à prix opaque (non publié). Verbatims pros très négatifs : \u0027arnaque, rien en retour en 6 mois.\u0027 Note 4.1/5 mais 22% de 1 étoile — inadapté aux architectes.",
|
||||||
|
"scoring": {
|
||||||
|
"remuneration": "⚠️",
|
||||||
|
"transparence": "❌",
|
||||||
|
"pratiques": "⚠️",
|
||||||
|
"ecologie": "❌",
|
||||||
|
"matching": "❌",
|
||||||
|
"tag_global": "a-eviter",
|
||||||
|
"justification_tag": "Transparence ❌ : aucun prix publié sur la page /pro/tarifs malgré une page dédiée — opacité tarifaire volontaire documentée (scraping T1 + page accessible sans prix). Matching ❌ : verbatim Trustpilot professionnel crédible (\u00276 propositions en 6 mois pour un abonnement coûteux\u0027, 1 732 avis, 22% de 1 étoile). Deux ❌ dont un critique suffisent à justifier \u0027à éviter\u0027."
|
||||||
|
},
|
||||||
|
"secteurs_servis": [
|
||||||
|
"renovation"
|
||||||
|
],
|
||||||
|
"zone_geo": "france-entiere",
|
||||||
|
"cout_entree": "abonnement",
|
||||||
|
"date_creation_fiche": "2026-05-06",
|
||||||
|
"date_derniere_maj": "2026-05-06",
|
||||||
|
"source_donnees": [
|
||||||
|
"https://r.jina.ai/https://www.habitatpresto.com/pro/tarifs",
|
||||||
|
"https://fr.trustpilot.com/review/habitatpresto.com"
|
||||||
|
],
|
||||||
|
"flag_validation_jules": true,
|
||||||
|
"commentaires": [
|
||||||
|
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "houzz-pro",
|
||||||
|
"nom": "Houzz Pro",
|
||||||
|
"url": "https://www.houzz.fr",
|
||||||
|
"type": "b2c-mise-en-relation",
|
||||||
|
"description": "## Présentation\nSolution SaaS tout-en-un pour professionnels de la rénovation et du design : gestion de projets, marketing, profil premium, publicité ciblée, plans 3D, devis, CRM, portail client. Plateforme américaine avec forte adoption internationale. 17 727 avis Trustpilot (4.1/5).\n\n## Modèle économique\nAbonnement mensuel couvrant l\u0027ensemble des fonctionnalités SaaS : portfolio, CRM, leads, publicité ciblée. Tarif estimé à ~250€/mois selon verbatim Trustpilot. Pas de commission sur les honoraires — l\u0027architecte conserve 100% de sa rémunération de mission.\n\n## Pour qui\nArchitectes d\u0027intérieur, architectes confirmés et studios cherchant un outil tout-en-un (portfolio + CRM + leads). Moins adapté aux architectes débutants ou aux profils cherchant uniquement des leads ponctuels sans investissement SaaS.\n\n## Points forts\nOutil SaaS complet (portfolio, CRM, devis, plan 3D intégrés). Forte notoriété internationale, large audience de particuliers. Pas de commission sur les missions — l\u0027architecte conserve 100% de ses honoraires.\n\n## Points de vigilance\nContrat difficile à résilier selon des verbatims Trustpilot : menace de poursuites judiciaires en cas de résiliation signalée. Coût mensuel significatif (~250€/mois estimé). Outil SaaS de gestion avant tout — la génération de leads est un effet secondaire, pas la valeur principale.",
|
||||||
|
"description_courte": "SaaS tout-en-un portfolio+CRM+leads pour architectes confirmés, sans commission sur honoraires. Contrat difficile à résilier — verbatim Trustpilot signale des menaces judiciaires en cas de résiliation anticipée.",
|
||||||
|
"scoring": {
|
||||||
|
"remuneration": "✅",
|
||||||
|
"transparence": "⚠️",
|
||||||
|
"pratiques": "⚠️",
|
||||||
|
"ecologie": "⚠️",
|
||||||
|
"matching": "⚠️",
|
||||||
|
"tag_global": "sous-reserve",
|
||||||
|
"justification_tag": "Rémunération ✅ (abonnement fixe, 0% commission sur les honoraires) mais pratiques de résiliation contractuelle problématiques signalées dans les verbatims Trustpilot (17 727 avis, 4.1/5). Trois axes critiques en ⚠️ — outil solide pour le portfolio mais engagement financier et contractuel à évaluer avec soin."
|
||||||
|
},
|
||||||
|
"secteurs_servis": [
|
||||||
|
"renovation",
|
||||||
|
"construction-neuve",
|
||||||
|
"architecture-interieure"
|
||||||
|
],
|
||||||
|
"zone_geo": "france-entiere",
|
||||||
|
"cout_entree": "abonnement",
|
||||||
|
"date_creation_fiche": "2026-05-06",
|
||||||
|
"date_derniere_maj": "2026-05-06",
|
||||||
|
"source_donnees": [
|
||||||
|
"https://r.jina.ai/https://www.houzz.fr/pro",
|
||||||
|
"https://fr.trustpilot.com/review/www.houzz.com"
|
||||||
|
],
|
||||||
|
"flag_validation_jules": false,
|
||||||
|
"commentaires": [
|
||||||
|
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "hello-archi",
|
||||||
|
"nom": "Hello Archi",
|
||||||
|
"url": "https://hello-archi.com",
|
||||||
|
"type": "b2c-mise-en-relation",
|
||||||
|
"description": "## Présentation\nPlateforme de mise en relation complète particuliers-architectes qualifiés, basée à Périgueux. Gère l\u0027intégralité du projet : sélection architecte, échanges, signature de contrat et paiement. Jusqu\u0027à 3 architectes mis en contact par projet. Couvre projets résidentiels et commerciaux, permis de construire inclus.\n\n## Modèle économique\nCommission sur le montant de la mission (taux non public). CGV documentées et accessibles sur /cgu. Pénalité de 100% des honoraires si l\u0027architecte signe un contrat hors plateforme sans le déclarer sous 10 jours. Frais administratifs de 350€ si le client est silencieux 15+ jours.\n\n## Pour qui\nArchitectes souhaitant une mise en relation structurée avec suivi de projet intégré (contrat, paiement). Adapté aux profils acceptant les contraintes contractuelles. Moins adapté aux freelances souhaitant une relation commerciale totalement libre.\n\n## Points forts\nCGV accessibles et détaillées — rare parmi les plateformes B2C du panel. Accompagnement expert tout au long du projet. Couverture nationale complète. Commission uniquement à la contractualisation, pas de frais d\u0027inscription documentés.\n\n## Points de vigilance\nPénalité de 100% des honoraires si contrat hors plateforme non déclaré sous 10 jours — clause très contraignante. Mécanisme de médiation en litige \u0027promis mais pas encore opérationnel\u0027 selon les CGV. Commission exacte non publiée.",
|
||||||
|
"description_courte": "Mise en relation avec CGV claires mais clause contraignante : pénalité 100% des honoraires si contrat signé hors plateforme sans déclaration sous 10 jours. Commission non publiée.",
|
||||||
|
"scoring": {
|
||||||
|
"remuneration": "⚠️",
|
||||||
|
"transparence": "⚠️",
|
||||||
|
"pratiques": "⚠️",
|
||||||
|
"ecologie": "⚠️",
|
||||||
|
"matching": "⚠️",
|
||||||
|
"tag_global": "sous-reserve",
|
||||||
|
"justification_tag": "Cinq axes en ⚠️ : commission non publiée, mécanisme de médiation non opérationnel selon les CGV elles-mêmes, et clause de pénalité de 100% des honoraires si contrat hors plateforme non déclaré sous 10 jours — contrainte contractuelle significative sans contrepartie documentée."
|
||||||
|
},
|
||||||
|
"secteurs_servis": [
|
||||||
|
"renovation",
|
||||||
|
"construction-neuve",
|
||||||
|
"architecture-interieure"
|
||||||
|
],
|
||||||
|
"zone_geo": "france-entiere",
|
||||||
|
"cout_entree": "commission",
|
||||||
|
"date_creation_fiche": "2026-05-06",
|
||||||
|
"date_derniere_maj": "2026-05-06",
|
||||||
|
"source_donnees": [
|
||||||
|
"https://r.jina.ai/https://hello-archi.com",
|
||||||
|
"https://hello-archi.com/cgu"
|
||||||
|
],
|
||||||
|
"flag_validation_jules": false,
|
||||||
|
"commentaires": [
|
||||||
|
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "archidvisor",
|
||||||
|
"nom": "Archidvisor",
|
||||||
|
"url": "https://www.archidvisor.com",
|
||||||
|
"type": "b2c-mise-en-relation",
|
||||||
|
"description": "## Présentation\nMarketplace archi et design fondée en novembre 2016 à Bordeaux par un architecte de formation. Connecte particuliers et entreprises avec architectes, architectes d\u0027intérieur, décorateurs, paysagistes et maîtres d\u0027œuvre. 2 200+ agences inscrites. Protection juridique MMA incluse. Lauréate des Rencontres des Entrepreneurs.\n\n## Modèle économique\nFreemium : inscription et référencement entièrement gratuits. Commission de 7-9% du montant total uniquement à la contractualisation (no cure no pay). Option abonnement Premium 1 499€/an ou 299€/mois pour visibilité accrue. Aucun frais si aucun projet conclu.\n\n## Pour qui\nArchitectes, architectes d\u0027intérieur, paysagistes et maîtres d\u0027œuvre cherchant une mise en relation sans investissement initial. Particulièrement adapté aux débutants (entrée sans frais) ou aux agences confirmées investissant dans le Premium pour la visibilité.\n\n## Points forts\nModèle no cure no pay — commission uniquement si projet signé. Entrée gratuite sans risque. Fondée par un architecte — compréhension du métier. Protection juridique MMA incluse. Couvre plusieurs profils (archi, déco, paysage).\n\n## Points de vigilance\nArchidvisor obtient une licence illimitée dans le temps pour utiliser photos et plans publiés à des fins marketing. Interdiction de contacter directement les clients hors plateforme. Verbatims Trustpilot signalent des profils avec projets IA et budgets irréalistes (3.9/5, 198 avis).",
|
||||||
|
"description_courte": "Plateforme fondée par un architecte, modèle no cure no pay (commission 7-9% à la signature seulement). Attention : licence illimitée sur vos photos et plans pour usage marketing Archidvisor.",
|
||||||
|
"scoring": {
|
||||||
|
"remuneration": "✅",
|
||||||
|
"transparence": "⚠️",
|
||||||
|
"pratiques": "⚠️",
|
||||||
|
"ecologie": "⚠️",
|
||||||
|
"matching": "⚠️",
|
||||||
|
"tag_global": "sous-reserve",
|
||||||
|
"justification_tag": "Rémunération ✅ : commission 7-9% no cure no pay — modèle favorable avec marge \u003e91% conservée. Mais clause de licence illimitée sur photos/plans (CGV vérifiées sur /p/cgu-cgv) et verbatims Trustpilot professionnels mitigés sur la qualité des leads (3.9/5, 198 avis). Quatre axes en ⚠️."
|
||||||
|
},
|
||||||
|
"secteurs_servis": [
|
||||||
|
"renovation",
|
||||||
|
"construction-neuve",
|
||||||
|
"architecture-interieure",
|
||||||
|
"paysage"
|
||||||
|
],
|
||||||
|
"zone_geo": "france-entiere",
|
||||||
|
"cout_entree": "freemium",
|
||||||
|
"date_creation_fiche": "2026-05-06",
|
||||||
|
"date_derniere_maj": "2026-05-06",
|
||||||
|
"source_donnees": [
|
||||||
|
"https://www.archidvisor.com/p/cgu-cgv",
|
||||||
|
"https://www.ooti.co/fr/blogs/networking-plateformes-architecture",
|
||||||
|
"https://fr.trustpilot.com/review/archidvisor.com"
|
||||||
|
],
|
||||||
|
"flag_validation_jules": false,
|
||||||
|
"commentaires": [
|
||||||
|
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "bam-archi",
|
||||||
|
"nom": "BAM Archi",
|
||||||
|
"url": "https://www.bam.archi",
|
||||||
|
"type": "b2c-mise-en-relation",
|
||||||
|
"description": "## Présentation\nService d\u0027accompagnement rénovation et construction à destination des particuliers. Sélection d\u0027architectes et artisans pour les clients via matching personnalisé. ~3 000 projets/an gérés, 6 000 agences référencées. Présence Paris, Marseille, Bordeaux, Lyon. Applications complémentaires : Aglo et Aglo Carbone (RE2020).\n\n## Modèle économique\nModèle économique non documenté publiquement. La grille tarifaire pour les professionnels n\u0027est pas accessible sur le site. Marketplace avec accompagnement client — commission ou frais d\u0027inscription pour les architectes à confirmer directement auprès de BAM.\n\n## Pour qui\nArchitectes confirmés dans les grandes métropoles françaises (Paris, Marseille, Bordeaux, Lyon) cherchant un volume de projets rénovation-construction. Fort intérêt pour les profils maîtrisant les outils RE2020 et bilan carbone.\n\n## Points forts\nIntégration des outils Aglo Carbone (RE2020) — signal positif sur l\u0027engagement environnemental. Accompagnement structuré des projets. Présence métropolitaine dense. Volume de projets significatif (~3 000/an).\n\n## Points de vigilance\nModèle économique entièrement opaque : impossible d\u0027évaluer le coût réel pour l\u0027architecte sans contact direct. 6 000 agences référencées = forte concurrence interne pour chaque lead. Pas de feedback communauté disponible (Trustpilot absent).",
|
||||||
|
"description_courte": "Accompagnement rénovation avec outils RE2020 et Aglo Carbone intégrés. Modèle économique entièrement opaque — coût pour l\u0027architecte impossible à évaluer sans contact direct avec BAM.",
|
||||||
|
"scoring": {
|
||||||
|
"remuneration": "⚠️",
|
||||||
|
"transparence": "⚠️",
|
||||||
|
"pratiques": "⚠️",
|
||||||
|
"ecologie": "✅",
|
||||||
|
"matching": "⚠️",
|
||||||
|
"tag_global": "sous-reserve",
|
||||||
|
"justification_tag": "Écologie ✅ : intégration d\u0027Aglo Carbone (RE2020) — effort concret et documenté sur la transition écologique. Mais modèle économique entièrement opaque pour les professionnels (aucune grille tarifaire publique) et absence totale de feedback communauté."
|
||||||
|
},
|
||||||
|
"secteurs_servis": [
|
||||||
|
"renovation",
|
||||||
|
"construction-neuve",
|
||||||
|
"architecture-interieure"
|
||||||
|
],
|
||||||
|
"zone_geo": "france-entiere",
|
||||||
|
"cout_entree": "gratuit",
|
||||||
|
"date_creation_fiche": "2026-05-06",
|
||||||
|
"date_derniere_maj": "2026-05-06",
|
||||||
|
"source_donnees": [
|
||||||
|
"https://r.jina.ai/https://www.bam.archi",
|
||||||
|
"https://www.ooti.co/fr/blogs/networking-plateformes-architecture"
|
||||||
|
],
|
||||||
|
"flag_validation_jules": false,
|
||||||
|
"commentaires": [
|
||||||
|
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "archibien",
|
||||||
|
"nom": "Archibien",
|
||||||
|
"url": "https://archibien.com",
|
||||||
|
"type": "b2c-mise-en-relation",
|
||||||
|
"description": "## Présentation\nPlateforme de courtage mettant des porteurs de projets en relation avec 3 architectes locaux en concurrence simultanée. Fondée ~2016. Qualification préalable du projet (faisabilité, budget). Consultations initiales offertes aux clients. Présente dans les grandes métropoles françaises. Secteurs : neuf, extension, rénovation, commercial.\n\n## Modèle économique\nModèle broker : les clients paient pour les services (consultation, étude de faisabilité). Les architectes semblent payer à la contractualisation ou pour accéder aux projets — grille tarifaire non accessible, CGV introuvables (/cgv → 404). Opacité tarifaire totale côté professionnel.\n\n## Pour qui\nÀ évaluer avec prudence pour les architectes indépendants : le modèle de 3 architectes en concurrence implique un travail préparatoire sans garantie de mission. Adapté uniquement aux agences ayant des ressources commerciales suffisantes pour absorber les pertes sur concours non retenus.\n\n## Points forts\nQualification préalable du projet côté client (faisabilité et budget évalués en amont). Présence dans les grandes métropoles. Pas de feedback négatif public visible — profil Trustpilot non revendiqué (0 avis).\n\n## Points de vigilance\nCGV introuvables (404) : aucune condition contractuelle vérifiable. Modèle \u00273 archis en concurrence\u0027 avec consultations offertes au client : risque de travail de conception non rémunéré, contraire au Code de déontologie. Opacité tarifaire totale.",
|
||||||
|
"description_courte": "Modèle 3 architectes en concurrence avec consultations offertes. CGV introuvables (/cgv → 404) — conditions contractuelles invérifiables, risque de travail non rémunéré non documenté.",
|
||||||
|
"scoring": {
|
||||||
|
"remuneration": "⚠️",
|
||||||
|
"transparence": "❌",
|
||||||
|
"pratiques": "⚠️",
|
||||||
|
"ecologie": "⚠️",
|
||||||
|
"matching": "⚠️",
|
||||||
|
"tag_global": "a-eviter",
|
||||||
|
"justification_tag": "Transparence ❌ : CGV introuvables (/cgv → 404) — aucune condition contractuelle vérifiable publiquement, opacité totale sur les conditions d\u0027utilisation (documenté lors du scraping T1). Modèle de concurrence simultanée entre 3 architectes avec consultations offertes soulève des questions déontologiques. Un axe critique ❌ suffit pour le tag \u0027à éviter\u0027."
|
||||||
|
},
|
||||||
|
"secteurs_servis": [
|
||||||
|
"renovation",
|
||||||
|
"construction-neuve",
|
||||||
|
"architecture-interieure"
|
||||||
|
],
|
||||||
|
"zone_geo": "france-entiere",
|
||||||
|
"cout_entree": "commission",
|
||||||
|
"date_creation_fiche": "2026-05-06",
|
||||||
|
"date_derniere_maj": "2026-05-06",
|
||||||
|
"source_donnees": [
|
||||||
|
"https://r.jina.ai/https://archibien.com",
|
||||||
|
"https://www.ooti.co/fr/blogs/networking-plateformes-architecture"
|
||||||
|
],
|
||||||
|
"flag_validation_jules": true,
|
||||||
|
"commentaires": [
|
||||||
|
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "archionline",
|
||||||
|
"nom": "Archionline",
|
||||||
|
"url": "https://www.archionline.com",
|
||||||
|
"type": "b2c-mise-en-relation",
|
||||||
|
"description": "## Présentation\nPlateforme de mise en relation particuliers-architectes appartenant au groupe Batiweb. Siège à Paris (19 rue d\u0027Hauteville, 75010). Déploiement d\u0027architectes sur site sous 1 semaine. Garantie décennale et protection juridique AXA incluses. 600+ plans de maison disponibles. Active depuis au moins 2017.\n\n## Modèle économique\nCommission de 5-15% sur le montant total des travaux selon la nature de la mission (conception, permis de construire, analyse entreprises). Étude initiale gratuite pour le client. Taux documenté via source tierce (blog Hello Archi, janvier 2025).\n\n## Pour qui\nEn théorie, architectes cherchant des projets résidentiels clés en main. En pratique, les retours Trustpilot très négatifs et les pratiques de démarchage abusif signalées exposent les architectes affiliés à des risques réputationnels significatifs.\n\n## Points forts\nCommission documentée dans une fourchette acceptable (5-15%). Garantie décennale et protection AXA incluses. Couverture nationale. Intégration Groupe Batiweb — synergies avec des médias pro du bâtiment.\n\n## Points de vigilance\nNote Trustpilot de 2.4/5 sur 207 avis — parmi les plus basses du panel. Démarchage abusif signalé : harcèlement téléphonique après dépôt de coordonnées client. Permis de construire non conformes aux PLU documentés. Ces pratiques exposent les architectes affiliés.",
|
||||||
|
"description_courte": "Commission 5-15% documentée mais note Trustpilot 2.4/5 sur 207 avis. Démarchage abusif et permis non conformes aux PLU signalés — risque réputationnel sérieux pour les architectes affiliés.",
|
||||||
|
"scoring": {
|
||||||
|
"remuneration": "✅",
|
||||||
|
"transparence": "⚠️",
|
||||||
|
"pratiques": "❌",
|
||||||
|
"ecologie": "⚠️",
|
||||||
|
"matching": "❌",
|
||||||
|
"tag_global": "a-eviter",
|
||||||
|
"justification_tag": "Pratiques ❌ : démarchage abusif (harcèlement téléphonique) et permis de construire non conformes aux PLU documentés dans les verbatims Trustpilot (2.4/5, 207 avis, sources publiques vérifiables). Matching ❌ convergent avec la note Trustpilot parmi les plus basses du panel. Un axe critique ❌ (Pratiques) suffit pour le tag \u0027à éviter\u0027."
|
||||||
|
},
|
||||||
|
"secteurs_servis": [
|
||||||
|
"renovation",
|
||||||
|
"construction-neuve",
|
||||||
|
"mar-conseil"
|
||||||
|
],
|
||||||
|
"zone_geo": "france-entiere",
|
||||||
|
"cout_entree": "commission",
|
||||||
|
"date_creation_fiche": "2026-05-06",
|
||||||
|
"date_derniere_maj": "2026-05-06",
|
||||||
|
"source_donnees": [
|
||||||
|
"https://r.jina.ai/https://www.archionline.com",
|
||||||
|
"https://blog.hello-archi.com/top-3-plateformes-pour-engager-un-architecte-a-moindre-cout/",
|
||||||
|
"https://fr.trustpilot.com/review/archionline.com"
|
||||||
|
],
|
||||||
|
"flag_validation_jules": true,
|
||||||
|
"commentaires": [
|
||||||
|
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "trouver-mon-architecte",
|
||||||
|
"nom": "Trouver-Mon-Architecte",
|
||||||
|
"url": "https://www.trouver-mon-architecte.fr",
|
||||||
|
"type": "b2c-mise-en-relation",
|
||||||
|
"description": "## Présentation\nAnnuaire et plateforme de mise en relation, se présentant comme \u0027#1 annuaire d\u0027architectes qualifiés en France\u0027. Service gratuit pour les particuliers. Couverture nationale complète (95 départements). Les particuliers reçoivent 2-3 architectes adaptés sous 24-48h. 1 204 abonnés LinkedIn.\n\n## Modèle économique\nFreemium : gratuit sans engagement pour les particuliers. Abonnement payant pour les architectes inscrits (tarif non publié), incluant des formations professionnelles continues. Pas de commission prélevée sur les honoraires — l\u0027architecte conserve 100% de sa rémunération de mission.\n\n## Pour qui\nArchitectes indépendants souhaitant développer leur activité sans commission par mission. Particulièrement adapté aux profils intéressés par la formation continue intégrée. Couverture nationale utile pour les architectes hors grandes métropoles.\n\n## Points forts\nPas de commission sur les honoraires — abonnement fixe. Formation continue incluse dans l\u0027abonnement (valeur ajoutée différenciante). Note 4.5/5 sur 361 avis Trustpilot. Verbatim archi positif documenté : ~40 demandes, 4 contrats signés.\n\n## Points de vigilance\nTarifs abonnement non publiés — à vérifier avant tout engagement. Taux de conversion estimé à 10% (4 contrats sur 40 demandes selon verbatim) — en dessous du seuil optimal. Veille marchés publics annoncée mais détails peu documentés.",
|
||||||
|
"description_courte": "Abonnement archi incluant formations professionnelles, sans commission sur honoraires. Taux de conversion ~10% selon verbatim (4/40 demandes). Tarifs non publiés à vérifier avant engagement.",
|
||||||
|
"scoring": {
|
||||||
|
"remuneration": "✅",
|
||||||
|
"transparence": "⚠️",
|
||||||
|
"pratiques": "✅",
|
||||||
|
"ecologie": "⚠️",
|
||||||
|
"matching": "⚠️",
|
||||||
|
"tag_global": "sous-reserve",
|
||||||
|
"justification_tag": "Rémunération ✅ (0% commission, abonnement fixe) et Pratiques ✅ (formations incluses, pas de mise en concurrence, structure respectueuse du métier). Mais tarifs abonnement non publiés et taux de conversion ~10% (en dessous du seuil ✅ de \u003e20%). Trois axes en ⚠️ — tag sous-réserve."
|
||||||
|
},
|
||||||
|
"secteurs_servis": [
|
||||||
|
"renovation",
|
||||||
|
"construction-neuve",
|
||||||
|
"urbanisme",
|
||||||
|
"mar-conseil"
|
||||||
|
],
|
||||||
|
"zone_geo": "france-entiere",
|
||||||
|
"cout_entree": "freemium",
|
||||||
|
"date_creation_fiche": "2026-05-06",
|
||||||
|
"date_derniere_maj": "2026-05-06",
|
||||||
|
"source_donnees": [
|
||||||
|
"https://r.jina.ai/https://www.trouver-mon-architecte.fr",
|
||||||
|
"https://fr.trustpilot.com/review/trouver-mon-architecte.fr"
|
||||||
|
],
|
||||||
|
"flag_validation_jules": false,
|
||||||
|
"commentaires": [
|
||||||
|
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "archiliste",
|
||||||
|
"nom": "Archiliste",
|
||||||
|
"url": "https://www.archiliste.fr",
|
||||||
|
"type": "b2c-mise-en-relation",
|
||||||
|
"description": "## Présentation\nAnnuaire et plateforme de présentation des architectes de France. 26 660 agences enregistrées. Publication de projets gratuite pour tous les professionnels. Héberge également actualités, ressources formations et événements networking. Secteurs couverts : résidentiel, commercial, intérieur, rénovation, équipements publics.\n\n## Modèle économique\nFreemium : inscription et publication de projets entièrement gratuits. Fonctionnalités avancées (marketing, publicité, portail pro) payantes — tarifs non précisés. Revenu basé sur la visibilité premium et les contacts marketing.\n\n## Pour qui\nArchitectes souhaitant une vitrine portfolio gratuite à l\u0027échelle nationale. Utile pour la présence en ligne sans engagement financier. Moins utile pour la génération directe de leads : plateforme passive sans matching actif.\n\n## Points forts\nInscription et présence de base entièrement gratuites. Large base d\u0027agences (26 660) = crédibilité annuaire. Présence dans les secteurs public et privé. Ressources et événements networking en complément.\n\n## Points de vigilance\nPlateforme annuaire passive : aucun mécanisme de mise en relation active ou de génération de leads qualifiés. 26 660 agences enregistrées signifient une visibilité très diluée sans abonnement premium. Pas de feedback communauté disponible.",
|
||||||
|
"description_courte": "Annuaire national avec présence de base gratuite pour 26 660 agences. Vitrine portfolio sans génération de leads active — utile en complément, pas comme canal de prospection principal.",
|
||||||
|
"scoring": {
|
||||||
|
"remuneration": "✅",
|
||||||
|
"transparence": "⚠️",
|
||||||
|
"pratiques": "✅",
|
||||||
|
"ecologie": "⚠️",
|
||||||
|
"matching": "⚠️",
|
||||||
|
"tag_global": "sous-reserve",
|
||||||
|
"justification_tag": "Rémunération ✅ (inscription gratuite, 0% commission) et Pratiques ✅ (annuaire passif respectueux, pas de mise en concurrence). Trois axes en ⚠️ dont Matching — la plateforme est passive et ne génère pas de leads qualifiés. Utile comme présence complémentaire."
|
||||||
|
},
|
||||||
|
"secteurs_servis": [
|
||||||
|
"renovation",
|
||||||
|
"construction-neuve",
|
||||||
|
"architecture-interieure",
|
||||||
|
"transversal"
|
||||||
|
],
|
||||||
|
"zone_geo": "france-entiere",
|
||||||
|
"cout_entree": "freemium",
|
||||||
|
"date_creation_fiche": "2026-05-06",
|
||||||
|
"date_derniere_maj": "2026-05-06",
|
||||||
|
"source_donnees": [
|
||||||
|
"https://r.jina.ai/https://www.archiliste.fr/annuaire"
|
||||||
|
],
|
||||||
|
"flag_validation_jules": false,
|
||||||
|
"commentaires": [
|
||||||
|
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "architectes-pour-tous",
|
||||||
|
"nom": "Architectes pour tous (CNOA)",
|
||||||
|
"url": "https://www.architectes-pour-tous.fr",
|
||||||
|
"type": "b2c-mise-en-relation",
|
||||||
|
"description": "## Présentation\nService officiel du Conseil National de l\u0027Ordre des Architectes (CNOA). Référence l\u0027ensemble des architectes exerçant légalement en France. Recherche par profil (particulier, pro, collectivité), type de projet, proximité géographique. Carte interactive. Intégré aux dispositifs France Rénov\u0027 et MaPrimeRénov\u0027.\n\n## Modèle économique\nTotalement gratuit pour les particuliers et pour les architectes. L\u0027inscription à l\u0027Ordre est une obligation légale — la plateforme n\u0027implique aucun coût supplémentaire. Aucune commission, aucun abonnement, aucun frais caché. Financé par les cotisations ordinales.\n\n## Pour qui\nTous les architectes inscrits à l\u0027Ordre : la présence est automatique. Particulièrement pertinent pour les architectes spécialisés MAR ou rénovation énergétique, directement référencés dans les dispositifs publics d\u0027aide à la rénovation.\n\n## Points forts\nGratuit, institutionnel, référencement automatique pour tout architecte inscrit à l\u0027Ordre. Intégration aux dispositifs publics (France Rénov\u0027, MaPrimeRénov\u0027). Crédibilité institutionnelle maximale. Couverture nationale totale.\n\n## Points de vigilance\nAnnuaire passif institutionnel : ne génère pas de leads directs. Tous les architectes inscrits à l\u0027Ordre y figurent — différenciation nulle. Outil de présence publique minimum, pas un canal de prospection actif.",
|
||||||
|
"description_courte": "Annuaire officiel du Conseil de l\u0027Ordre, gratuit et intégré à France Rénov\u0027 et MaPrimeRénov\u0027. Présence automatique pour tout architecte inscrit — ne génère pas de leads directs.",
|
||||||
|
"scoring": {
|
||||||
|
"remuneration": "✅",
|
||||||
|
"transparence": "✅",
|
||||||
|
"pratiques": "✅",
|
||||||
|
"ecologie": "✅",
|
||||||
|
"matching": "⚠️",
|
||||||
|
"tag_global": "recommande",
|
||||||
|
"justification_tag": "Quatre axes en ✅ : gratuit, institutionnel, intégré aux dispositifs de rénovation énergétique (France Rénov\u0027, MaPrimeRénov\u0027), pratiques respectueuses de l\u0027Ordre. Seul le matching est en ⚠️ du fait de la nature passive de l\u0027annuaire — mais la présence y est obligatoire et sans coût."
|
||||||
|
},
|
||||||
|
"secteurs_servis": [
|
||||||
|
"renovation",
|
||||||
|
"construction-neuve",
|
||||||
|
"urbanisme",
|
||||||
|
"mar-conseil",
|
||||||
|
"transversal"
|
||||||
|
],
|
||||||
|
"zone_geo": "france-entiere",
|
||||||
|
"cout_entree": "gratuit",
|
||||||
|
"date_creation_fiche": "2026-05-06",
|
||||||
|
"date_derniere_maj": "2026-05-06",
|
||||||
|
"source_donnees": [
|
||||||
|
"https://r.jina.ai/https://www.architectes-pour-tous.fr"
|
||||||
|
],
|
||||||
|
"flag_validation_jules": false,
|
||||||
|
"commentaires": [
|
||||||
|
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "pipcke",
|
||||||
|
"nom": "Pipcke",
|
||||||
|
"url": "https://pipcke.fr",
|
||||||
|
"type": "b2c-mise-en-relation",
|
||||||
|
"description": "## Présentation\nPlateforme d\u0027architecture d\u0027intérieur et décoration en ligne. Met en relation clients avec architectes d\u0027intérieur et décorateurs via mood boards, shopping list et conception 3D photoréaliste. 1 000+ espaces transformés. 500+ marques partenaires avec remises négociées. Approche projet par pièce.\n\n## Modèle économique\nForfait par pièce : Essentiel 90€ (1 sem, mood boards + shopping list), Incontournable 155€ (1-2 sem, +3D + révisions), Fantastique 235€ (2-3 sem, 2 designers, 3D illimitées). Revenus complémentaires via partenariats marques mobilier. Tarifs publics et clairement affichés.\n\n## Pour qui\nArchitectes d\u0027intérieur et décorateurs cherchant un flux de projets de petite envergure (pièce unique). Non adapté aux architectes HMONP ou MOE : scope exclusivement déco, sans permis de construire ni maîtrise d\u0027œuvre.\n\n## Points forts\nTarification entièrement publique et transparente — rare dans le panel B2C. Modèle clair sans surprise. Partenariats marques mobilier avec remises clients. Approche structurée par pièce facilitant la gestion du temps professionnel.\n\n## Points de vigilance\nRevenus unitaires faibles (90-235€/pièce) pour 1-3 semaines de travail. Scope exclusivement déco intérieure. Partenariats marques peuvent orienter les recommandations vers des produits sponsors. Pas de feedback communauté disponible.",
|
||||||
|
"description_courte": "Plateforme déco par pièce, tarifs publics et clairs (90-235€). Réservée aux architectes d\u0027intérieur — scope exclusivement déco, revenus unitaires faibles pour 1-3 semaines de travail.",
|
||||||
|
"scoring": {
|
||||||
|
"remuneration": "⚠️",
|
||||||
|
"transparence": "✅",
|
||||||
|
"pratiques": "✅",
|
||||||
|
"ecologie": "⚠️",
|
||||||
|
"matching": "⚠️",
|
||||||
|
"tag_global": "sous-reserve",
|
||||||
|
"justification_tag": "Transparence ✅ (tarification publique, claire, affichée) et Pratiques ✅ (pas de concours, scope honnête, modèle sain). Rémunération en ⚠️ car les forfaits 90-235€/pièce génèrent des revenus unitaires faibles pour 1-3 semaines de travail de conception. Scope très limité (déco intérieure uniquement)."
|
||||||
|
},
|
||||||
|
"secteurs_servis": [
|
||||||
|
"architecture-interieure"
|
||||||
|
],
|
||||||
|
"zone_geo": "france-entiere",
|
||||||
|
"cout_entree": "abonnement",
|
||||||
|
"date_creation_fiche": "2026-05-06",
|
||||||
|
"date_derniere_maj": "2026-05-06",
|
||||||
|
"source_donnees": [
|
||||||
|
"https://r.jina.ai/https://pipcke.fr"
|
||||||
|
],
|
||||||
|
"flag_validation_jules": false,
|
||||||
|
"commentaires": [
|
||||||
|
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "moncoachdeco",
|
||||||
|
"nom": "MonCoachDéco",
|
||||||
|
"url": "https://moncoachdeco.com",
|
||||||
|
"type": "b2c-mise-en-relation",
|
||||||
|
"description": "## Présentation\nMarketplace numérique mettant en relation architectes d\u0027intérieur et décorateurs avec des clients. Inscription de base gratuite. Les professionnels reçoivent des leads projets correspondant à leur localisation et compétences, puis choisissent quels projets accepter.\n\n## Modèle économique\nFreemium : profil de base gratuit. Accès aux coordonnées clients payant (achat à la carte ou abonnement illimité). Options additionnelles : nom de domaine personnalisé, logiciel comptable intégré. Tarifs non publiés sur le site principal.\n\n## Pour qui\nArchitectes d\u0027intérieur et décorateurs souhaitant un flux de leads qualifiés dans leur zone géographique. Non adapté aux architectes HMONP ou MOE : scope exclusivement architecture intérieure et décoration, sans maîtrise d\u0027œuvre ni permis de construire.\n\n## Points forts\nLiberté de sélection : le professionnel choisit les projets qu\u0027il accepte. Inscription sans frais initiale. Ciblage géographique et par compétences. Services additionnels (domaine, comptabilité) intégrés en option.\n\n## Points de vigilance\nTarifs non publiés — impossible d\u0027évaluer le rapport coût/bénéfice avant inscription. Coût par lead potentiellement élevé si le taux de conversion est faible. Pas de feedback communauté disponible — qualité des leads inconnue.",
|
||||||
|
"description_courte": "Leads architectes d\u0027intérieur avec liberté de sélection des projets, inscription gratuite. Tarifs d\u0027accès aux coordonnées non publiés — coût réel impossible à évaluer sans inscription.",
|
||||||
|
"scoring": {
|
||||||
|
"remuneration": "⚠️",
|
||||||
|
"transparence": "⚠️",
|
||||||
|
"pratiques": "✅",
|
||||||
|
"ecologie": "⚠️",
|
||||||
|
"matching": "⚠️",
|
||||||
|
"tag_global": "sous-reserve",
|
||||||
|
"justification_tag": "Pratiques ✅ : liberté totale de sélection des projets, pas de mise en concurrence, inscription sans frais initiale. Mais tarifs non publiés (⚠️ Transparence) et absence de feedback communauté rendent impossible l\u0027évaluation du rapport coût/efficacité."
|
||||||
|
},
|
||||||
|
"secteurs_servis": [
|
||||||
|
"architecture-interieure"
|
||||||
|
],
|
||||||
|
"zone_geo": "france-entiere",
|
||||||
|
"cout_entree": "freemium",
|
||||||
|
"date_creation_fiche": "2026-05-06",
|
||||||
|
"date_derniere_maj": "2026-05-06",
|
||||||
|
"source_donnees": [
|
||||||
|
"https://r.jina.ai/https://moncoachdeco.com/plateforme-decorateur-architecte"
|
||||||
|
],
|
||||||
|
"flag_validation_jules": false,
|
||||||
|
"commentaires": [
|
||||||
|
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "eldo-pro",
|
||||||
|
"nom": "Eldo / EldoPro",
|
||||||
|
"url": "https://www.eldo.com",
|
||||||
|
"type": "b2c-mise-en-relation",
|
||||||
|
"description": "## Présentation\nPlateforme d\u0027avis entre voisins et de mise en relation avec des professionnels qualifiés du bâtiment. 1 200 pros qualifiés en France, 8 000+ projets accompagnés. Présence dans 7 grandes métropoles (Toulouse, Paris, Bordeaux, Marseille, Lyon, Montpellier, Lille). Coordonnées transmises uniquement au professionnel choisi par le client.\n\n## Modèle économique\nLead generation pour EldoPro (artisans individuels) et EldoNetwork (réseaux et marques). Tarifs non publiés. L\u0027architecte paie pour les leads reçus selon un modèle à la demande ou abonnement — conditions exactes à confirmer directement.\n\n## Pour qui\nArtisans BTP généralistes en priorité. Les architectes sont absents de la description principale de la plateforme — leur présence est marginale et non valorisée dans l\u0027offre Eldo.\n\n## Points forts\nSystème d\u0027avis entre voisins = leads avec recommandation sociale. Coordonnées transmises uniquement au professionnel choisi (pas de multi-diffusion massive). Présence métropolitaine dense dans 7 grandes villes.\n\n## Points de vigilance\nArchitectes très peu représentés dans l\u0027offre principale. Modèle orienté artisanat BTP, pas architecture de conception ou maîtrise d\u0027œuvre. Tarifs opaques. Pas de feedback communauté disponible pour évaluer la qualité des leads archi.",
|
||||||
|
"description_courte": "Plateforme leads artisans BTP avec recommandations entre voisins. Architectes absents de l\u0027offre principale — inadapté aux missions de conception ou maîtrise d\u0027œuvre.",
|
||||||
|
"scoring": {
|
||||||
|
"remuneration": "⚠️",
|
||||||
|
"transparence": "⚠️",
|
||||||
|
"pratiques": "⚠️",
|
||||||
|
"ecologie": "⚠️",
|
||||||
|
"matching": "⚠️",
|
||||||
|
"tag_global": "sous-reserve",
|
||||||
|
"justification_tag": "Cinq axes en ⚠️ : tarification opaque, architectes marginaux dans l\u0027offre principale, pas de feedback communauté. Seul signal positif : coordonnées transmises uniquement au pro choisi (pas de revente massive). Inadapté comme canal principal pour les architectes."
|
||||||
|
},
|
||||||
|
"secteurs_servis": [
|
||||||
|
"renovation",
|
||||||
|
"transversal"
|
||||||
|
],
|
||||||
|
"zone_geo": "france-entiere",
|
||||||
|
"cout_entree": "lead-paye",
|
||||||
|
"date_creation_fiche": "2026-05-06",
|
||||||
|
"date_derniere_maj": "2026-05-06",
|
||||||
|
"source_donnees": [
|
||||||
|
"https://r.jina.ai/https://www.eldo.com"
|
||||||
|
],
|
||||||
|
"flag_validation_jules": false,
|
||||||
|
"commentaires": [
|
||||||
|
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "france-renov-annuaire",
|
||||||
|
"nom": "France Rénov\u0027 — Annuaire pro",
|
||||||
|
"url": "https://france-renov.gouv.fr/annuaires-professionnels/artisan-rge-architecte",
|
||||||
|
"type": "b2c-mise-en-relation",
|
||||||
|
"description": "## Présentation\nAnnuaire officiel ANAH permettant aux particuliers de trouver des professionnels RGE et architectes référencés pour travaux de rénovation énergétique. Service public entièrement gratuit. Mécanisme anti-fraude intégré. Référence obligatoire pour les chantiers éligibles MaPrimeRénov\u0027.\n\n## Modèle économique\nTotalement gratuit, financé par l\u0027État (ANAH). Aucune commission ni frais pour les architectes référencés. L\u0027inscription nécessite une certification MAR ou qualification RGE pertinente — elle-même conditionnée à des critères professionnels rigoureux.\n\n## Pour qui\nArchitectes certifiés MAR (Mon Accompagnateur Rénov\u0027) ou travaillant sur des projets de rénovation énergétique performante. Très pertinent pour les profils spécialisés énergie. Peu d\u0027intérêt pour les architectes exclusivement orientés neuf ou décoration.\n\n## Points forts\nService public gratuit adossé aux dispositifs MaPrimeRénov\u0027. Crédibilité institutionnelle maximale. Seuls les pros certifiés RGE/MAR référencés — signal qualité pour les clients. Génère des leads ciblés sur la rénovation énergétique.\n\n## Points de vigilance\nAnnuaire passif — c\u0027est le particulier qui recherche, pas un matching actif. Pertinent uniquement pour les architectes certifiés MAR ou RGE. Génération de leads limitée si peu de communication publique sur le dispositif.",
|
||||||
|
"description_courte": "Annuaire officiel ANAH gratuit pour architectes certifiés MAR ou RGE. Leads ciblés rénovation énergétique via MaPrimeRénov\u0027. Peu pertinent pour les profils non certifiés.",
|
||||||
|
"scoring": {
|
||||||
|
"remuneration": "✅",
|
||||||
|
"transparence": "✅",
|
||||||
|
"pratiques": "✅",
|
||||||
|
"ecologie": "✅",
|
||||||
|
"matching": "⚠️",
|
||||||
|
"tag_global": "recommande",
|
||||||
|
"justification_tag": "Quatre axes en ✅ : service public gratuit (ANAH), institutionnel, intégré MaPrimeRénov\u0027, focalisé rénovation énergétique (MAR, RGE). Seul le matching est en ⚠️ car la plateforme est passive et le volume de leads dépend des campagnes de communication publique sur le dispositif."
|
||||||
|
},
|
||||||
|
"secteurs_servis": [
|
||||||
|
"renovation",
|
||||||
|
"mar-conseil"
|
||||||
|
],
|
||||||
|
"zone_geo": "france-entiere",
|
||||||
|
"cout_entree": "gratuit",
|
||||||
|
"date_creation_fiche": "2026-05-06",
|
||||||
|
"date_derniere_maj": "2026-05-06",
|
||||||
|
"source_donnees": [
|
||||||
|
"https://r.jina.ai/https://france-renov.gouv.fr/annuaires-professionnels/artisan-rge-architecte"
|
||||||
|
],
|
||||||
|
"flag_validation_jules": false,
|
||||||
|
"commentaires": [
|
||||||
|
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "boamp",
|
||||||
|
"nom": "BOAMP",
|
||||||
|
"url": "https://www.boamp.fr",
|
||||||
|
"type": "appel-offre-public",
|
||||||
|
"description": "## Présentation\nBulletin Officiel des Annonces des Marchés Publics — référence institutionnelle pour tous les marchés publics formels français et européens. Géré directement par l\u0027État. Publie les avis publics à la concurrence (AAPC), avis de concession et avis d\u0027attribution.\n\n## Modèle économique\nService public entièrement gratuit. Veille personnalisée jusqu\u0027à 10 alertes configurables. Notification quotidienne des nouveaux avis. Accès aux DCE possible. Aucun frais pour les entreprises candidates, quel que soit le volume d\u0027AO consultés.\n\n## Pour qui\nTous les architectes et bureaux d\u0027études souhaitant répondre à des marchés publics de maîtrise d\u0027œuvre. Source primaire officielle — indispensable pour toute démarche sérieuse de réponse aux AO publics. Complémentaire aux agrégateurs spécialisés.\n\n## Points forts\nSource officielle et exhaustive de tous les marchés publics formels. Totalement gratuit. Alertes email personnalisées (10 profils max). Accès aux DCE directement. Référence légale — toute publication y est obligatoire.\n\n## Points de vigilance\nInterface moins ergonomique que les agrégateurs spécialisés (AppelArchi, Instao). Pas de résumés IA ni de filtres avancés par profil archi. Nécessite une veille active ou des alertes précises pour être efficace.",
|
||||||
|
"description_courte": "Source officielle gratuite de tous les marchés publics français. Interface brute — à coupler avec un agrégateur spécialisé (AppelArchi, Instao) pour une veille efficace adaptée aux profils archi.",
|
||||||
|
"scoring": {
|
||||||
|
"remuneration": null,
|
||||||
|
"transparence": "✅",
|
||||||
|
"pratiques": null,
|
||||||
|
"ecologie": null,
|
||||||
|
"matching": "✅",
|
||||||
|
"tag_global": "recommande",
|
||||||
|
"justification_tag": "Scoring simplifié 2 axes (plateforme appels d\u0027offres publics). Source officielle de l\u0027État : transparence totale (✅) et couverture exhaustive de tous les marchés formels (✅ Matching)."
|
||||||
|
},
|
||||||
|
"secteurs_servis": [
|
||||||
|
"urbanisme",
|
||||||
|
"mar-conseil",
|
||||||
|
"transversal"
|
||||||
|
],
|
||||||
|
"zone_geo": "france-entiere",
|
||||||
|
"cout_entree": "freemium",
|
||||||
|
"date_creation_fiche": "2026-05-06",
|
||||||
|
"date_derniere_maj": "2026-05-06",
|
||||||
|
"source_donnees": [
|
||||||
|
"https://r.jina.ai/https://www.boamp.fr"
|
||||||
|
],
|
||||||
|
"flag_validation_jules": false,
|
||||||
|
"commentaires": [
|
||||||
|
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "e-marchespublics",
|
||||||
|
"nom": "E-marchespublics.com",
|
||||||
|
"url": "https://www.e-marchespublics.com",
|
||||||
|
"type": "appel-offre-public",
|
||||||
|
"description": "## Présentation\nPlateforme française d\u0027agrégation marchés publics permettant découverte d\u0027AO et soumission de candidatures électroniques. Agrège BOAMP, JOUE et sources régionales. 991 762 entreprises inscrites. 58,8M documents téléchargés. 600 000+ opportunités annuelles. Réponse dématérialisée sécurisée en 5 minutes.\n\n## Modèle économique\nFreemium : compte gratuit avec recherche, alertes email quotidiennes et dépôt de candidatures. Fonctionnalités avancées payantes (monitoring détaillé, complétion auto formulaires, filtres avancés). Accès complet aux fonctions essentielles sans engagement financier.\n\n## Pour qui\nArchitectes et bureaux d\u0027études souhaitant une veille AO mutualisée et une soumission dématérialisée simplifiée. Marchés MOE et architecture confirmés dans les résultats (ex: Institut Bergonie, Logeal Immobilière).\n\n## Points forts\nAgrégation multi-sources (BOAMP + JOUE + régionaux). Dépôt de candidature dématérialisé intégré. Large base d\u0027entreprises inscrites (991k). Gratuit pour les fonctions essentielles. Marchés MOE archi confirmés.\n\n## Points de vigilance\nFonctionnalités avancées payantes sans tarifs précisés. Volume très large (600k+ AO/an) nécessitant des filtres précis pour isoler les marchés MOE archi pertinents.",
|
||||||
|
"description_courte": "Agrégateur multi-sources marchés publics (BOAMP + JOUE + régionaux) avec soumission dématérialisée gratuite. Marchés MOE archi confirmés — fonctions avancées payantes sans tarifs affichés.",
|
||||||
|
"scoring": {
|
||||||
|
"remuneration": null,
|
||||||
|
"transparence": "✅",
|
||||||
|
"pratiques": null,
|
||||||
|
"ecologie": null,
|
||||||
|
"matching": "✅",
|
||||||
|
"tag_global": "recommande",
|
||||||
|
"justification_tag": "Scoring simplifié 2 axes (plateforme appels d\u0027offres publics). Modèle freemium clair avec accès gratuit documenté (✅ Transparence) et agrégation multi-sources avec marchés MOE archi confirmés (✅ Matching)."
|
||||||
|
},
|
||||||
|
"secteurs_servis": [
|
||||||
|
"transversal"
|
||||||
|
],
|
||||||
|
"zone_geo": "france-entiere",
|
||||||
|
"cout_entree": "freemium",
|
||||||
|
"date_creation_fiche": "2026-05-06",
|
||||||
|
"date_derniere_maj": "2026-05-06",
|
||||||
|
"source_donnees": [
|
||||||
|
"https://r.jina.ai/https://www.e-marchespublics.com"
|
||||||
|
],
|
||||||
|
"flag_validation_jules": false,
|
||||||
|
"commentaires": [
|
||||||
|
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "centrale-des-marches",
|
||||||
|
"nom": "Centrale des Marchés",
|
||||||
|
"url": "https://centraledesmarches.com",
|
||||||
|
"type": "appel-offre-public",
|
||||||
|
"description": "## Présentation\nPlateforme de veille marchés publics et privés lancée en 2021 par Medialex. Agrège BOAMP, JOUE, presse régionale et marchés privés. 16 005 avis actifs, 48 857 acheteurs publics identifiés. 1 515 opportunités listées en catégorie architecture/construction/ingénierie. Formations marchés publics disponibles.\n\n## Modèle économique\nFreemium : alertes email gratuites pour les entreprises candidates. Solutions payantes de dématérialisation pour les acheteurs publics. Tarifs d\u0027abonnement pour les fonctions avancées non précisés sur la homepage.\n\n## Pour qui\nArchitectes souhaitant couvrir à la fois les marchés publics et privés dans une seule interface. La double couverture est un différenciateur intéressant pour les profils cherchant un flux diversifié de projets.\n\n## Points forts\nDouble couverture marchés publics + privés — rare dans le panel. 1 515 opportunités archi/construction. Alertes email gratuites. Formations marchés publics disponibles — valeur ajoutée pour les profils débutants en AO.\n\n## Points de vigilance\nTarifs abonnement pour les fonctions avancées non précisés. Plateforme lancée en 2021 — moins mature que BOAMP ou e-marchespublics. Marchés privés : qualité et fiabilité des données à confirmer.",
|
||||||
|
"description_courte": "Veille marchés publics + privés avec alertes email gratuites. Double couverture différenciante mais tarifs abonnement opaques et plateforme jeune (lancée 2021).",
|
||||||
|
"scoring": {
|
||||||
|
"remuneration": null,
|
||||||
|
"transparence": "⚠️",
|
||||||
|
"pratiques": null,
|
||||||
|
"ecologie": null,
|
||||||
|
"matching": "✅",
|
||||||
|
"tag_global": "sous-reserve",
|
||||||
|
"justification_tag": "Scoring simplifié 2 axes (plateforme appels d\u0027offres publics). Matching ✅ (double couverture public+privé, 1 515 opportunités archi). Transparence ⚠️ : tarifs des fonctions avancées non publiés sur la homepage. Configuration 1✅ + 1⚠️ → sous-réserve."
|
||||||
|
},
|
||||||
|
"secteurs_servis": [
|
||||||
|
"transversal"
|
||||||
|
],
|
||||||
|
"zone_geo": "france-entiere",
|
||||||
|
"cout_entree": "freemium",
|
||||||
|
"date_creation_fiche": "2026-05-06",
|
||||||
|
"date_derniere_maj": "2026-05-06",
|
||||||
|
"source_donnees": [
|
||||||
|
"https://r.jina.ai/https://centraledesmarches.com"
|
||||||
|
],
|
||||||
|
"flag_validation_jules": false,
|
||||||
|
"commentaires": [
|
||||||
|
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "appelarchi",
|
||||||
|
"nom": "AppelArchi",
|
||||||
|
"url": "https://appelarchi.fr",
|
||||||
|
"type": "appel-offre-public",
|
||||||
|
"description": "## Présentation\nPlateforme spécialisée pour les professionnels de l\u0027architecture. Agrège BOAMP, JOUE, TED et sources régionales. 300+ opportunités analysées quotidiennement. Filtres avancés par profil archi, suivi des lauréats, résumés IA. Inclut DOM-TOM. Conçue spécifiquement pour les cabinets d\u0027architecture.\n\n## Modèle économique\nAccès conditionnel suggéré par le CTA \u0027accéder à la plateforme\u0027. Modèle exact (gratuit/payant/abonnement) non précisé sur la homepage. Tarification à confirmer après inscription. Présumé abonnement compte tenu de la spécialisation du service.\n\n## Pour qui\nCabinets d\u0027architecture et architectes indépendants souhaitant une veille AO spécialisée avec valeur ajoutée IA. La spécialisation sur le profil archi est un avantage significatif face aux agrégateurs généralistes.\n\n## Points forts\nSeule plateforme du panel 100% dédiée aux marchés archi. Résumés IA des AO. Suivi des lauréats. Filtres avancés par profil. Couverture DOM-TOM. 300+ opportunités analysées quotidiennement.\n\n## Points de vigilance\nTarification entièrement opaque avant inscription — risque d\u0027engagement sans visibilité sur les coûts. Service présumé payant mais aucune information tarifaire publique disponible.",
|
||||||
|
"description_courte": "Plateforme 100% dédiée aux marchés publics archi, résumés IA et suivi des lauréats. Tarification entièrement opaque avant inscription — à tester avant tout engagement financier.",
|
||||||
|
"scoring": {
|
||||||
|
"remuneration": null,
|
||||||
|
"transparence": "⚠️",
|
||||||
|
"pratiques": null,
|
||||||
|
"ecologie": null,
|
||||||
|
"matching": "✅",
|
||||||
|
"tag_global": "sous-reserve",
|
||||||
|
"justification_tag": "Scoring simplifié 2 axes (plateforme appels d\u0027offres publics). Matching ✅ : spécialisation archi complète, résumés IA, suivi lauréats — outil différenciant dans le panel. Transparence ⚠️ : tarification entièrement opaque avant inscription. Configuration 1✅ + 1⚠️ → sous-réserve."
|
||||||
|
},
|
||||||
|
"secteurs_servis": [
|
||||||
|
"urbanisme",
|
||||||
|
"mar-conseil",
|
||||||
|
"transversal"
|
||||||
|
],
|
||||||
|
"zone_geo": "france-entiere",
|
||||||
|
"cout_entree": "abonnement",
|
||||||
|
"date_creation_fiche": "2026-05-06",
|
||||||
|
"date_derniere_maj": "2026-05-06",
|
||||||
|
"source_donnees": [
|
||||||
|
"https://r.jina.ai/https://appelarchi.fr"
|
||||||
|
],
|
||||||
|
"flag_validation_jules": false,
|
||||||
|
"commentaires": [
|
||||||
|
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "akkel",
|
||||||
|
"nom": "Akkel",
|
||||||
|
"url": "https://www.akkel.fr",
|
||||||
|
"type": "appel-offre-public",
|
||||||
|
"description": "## Présentation\nPlateforme de veille automatisée pour marchés publics. Recommandations personnalisées basées sur l\u0027activité réelle de l\u0027entreprise, sans configuration initiale manuelle. 46 000+ notices 2024, 12 157 AO actifs, 95 155 notices 2025. Couvre les publications officielles complètes, mises à jour quotidiennement.\n\n## Modèle économique\nTrial gratuit de 21 jours avec accès à toutes les fonctionnalités. Tarifs post-trial non affichés publiquement — à confirmer après la période d\u0027essai. Modèle présumé abonnement.\n\n## Pour qui\nArchitectes et bureaux d\u0027études souhaitant une veille entièrement automatisée sans configuration manuelle complexe. L\u0027algorithme de recommandation personnalisée basé sur l\u0027historique d\u0027activité est un différenciateur pour les profils expérimentés en marchés publics.\n\n## Points forts\nRecommandations personnalisées automatiques (aucune configuration manuelle). Trial 21 jours gratuit avec accès complet. Volume élevé : 95k+ notices 2025. Couverture complète des publications officielles.\n\n## Points de vigilance\nTarifs post-trial non affichés — impossible d\u0027anticiper le coût avant la fin de l\u0027essai. Plateforme moins connue que BOAMP ou e-marchespublics — maturité à confirmer. Trial gratuit peut créer un biais d\u0027engagement.",
|
||||||
|
"description_courte": "Veille marchés publics automatisée avec recommandations personnalisées, trial 21 jours gratuit. Tarifs post-trial opaques — à comparer avec d\u0027autres agrégateurs avant engagement.",
|
||||||
|
"scoring": {
|
||||||
|
"remuneration": null,
|
||||||
|
"transparence": "⚠️",
|
||||||
|
"pratiques": null,
|
||||||
|
"ecologie": null,
|
||||||
|
"matching": "✅",
|
||||||
|
"tag_global": "sous-reserve",
|
||||||
|
"justification_tag": "Scoring simplifié 2 axes (plateforme appels d\u0027offres publics). Matching ✅ : recommandations personnalisées automatiques, 95k+ notices 2025. Transparence ⚠️ : tarifs post-trial non publiés — le trial gratuit ne permet pas d\u0027anticiper le coût réel. Configuration 1✅ + 1⚠️ → sous-réserve."
|
||||||
|
},
|
||||||
|
"secteurs_servis": [
|
||||||
|
"transversal"
|
||||||
|
],
|
||||||
|
"zone_geo": "france-entiere",
|
||||||
|
"cout_entree": "freemium",
|
||||||
|
"date_creation_fiche": "2026-05-06",
|
||||||
|
"date_derniere_maj": "2026-05-06",
|
||||||
|
"source_donnees": [
|
||||||
|
"https://r.jina.ai/https://www.akkel.fr"
|
||||||
|
],
|
||||||
|
"flag_validation_jules": false,
|
||||||
|
"commentaires": [
|
||||||
|
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "marches-publics-gouv",
|
||||||
|
"nom": "Marchés-publics.gouv.fr (PLACE)",
|
||||||
|
"url": "https://www.marches-publics.gouv.fr",
|
||||||
|
"type": "appel-offre-public",
|
||||||
|
"description": "## Présentation\nPlateforme officielle de l\u0027État français pour les marchés publics et leur dématérialisation. Agrège les marchés de l\u0027ensemble des entités publiques françaises. Référence institutionnelle pour la soumission de candidatures. Complémentaire au BOAMP pour la réponse aux AO.\n\n## Modèle économique\nAccès totalement gratuit pour les entreprises candidates. Service public financé par l\u0027État. Aucune contrainte financière pour la consultation des AO ou la soumission de candidatures. Pas d\u0027abonnement, pas de frais de dossier.\n\n## Pour qui\nTous les architectes et bureaux d\u0027études répondant à des marchés publics. Référence institutionnelle incontournable pour la soumission de candidatures dématérialisées — complémentaire à la veille sur BOAMP ou les agrégateurs spécialisés.\n\n## Points forts\nService public officiel, gratuit, institutionnel. Référence légale pour la dématérialisation des candidatures. Couverture de toutes les entités publiques françaises. Complémentaire aux outils de veille.\n\n## Points de vigilance\nURL principale instable (erreur 400 signalée lors du scraping T1). Interface à prendre en main — moins ergonomique que les agrégateurs privés. Outil de soumission avant tout, pas de veille proactive.",
|
||||||
|
"description_courte": "Plateforme officielle gratuite de l\u0027État pour la soumission dématérialisée de candidatures AO. Outil institutionnel de référence — à coupler avec un agrégateur pour la veille proactive.",
|
||||||
|
"scoring": {
|
||||||
|
"remuneration": null,
|
||||||
|
"transparence": "✅",
|
||||||
|
"pratiques": null,
|
||||||
|
"ecologie": null,
|
||||||
|
"matching": "✅",
|
||||||
|
"tag_global": "recommande",
|
||||||
|
"justification_tag": "Scoring simplifié 2 axes (plateforme appels d\u0027offres publics). Service officiel de l\u0027État entièrement gratuit (✅ Transparence) et couverture institutionnelle de toutes les entités publiques françaises (✅ Matching). URL instable signalée en T1 — à surveiller."
|
||||||
|
},
|
||||||
|
"secteurs_servis": [
|
||||||
|
"transversal"
|
||||||
|
],
|
||||||
|
"zone_geo": "france-entiere",
|
||||||
|
"cout_entree": "gratuit",
|
||||||
|
"date_creation_fiche": "2026-05-06",
|
||||||
|
"date_derniere_maj": "2026-05-06",
|
||||||
|
"source_donnees": [
|
||||||
|
|
||||||
|
],
|
||||||
|
"flag_validation_jules": false,
|
||||||
|
"commentaires": [
|
||||||
|
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "instao",
|
||||||
|
"nom": "Instao",
|
||||||
|
"url": "https://www.instao.fr",
|
||||||
|
"type": "appel-offre-public",
|
||||||
|
"description": "## Présentation\nPlateforme IA de veille marchés publics français, en beta en 2026. Agrège BOAMP, PLACE, e-marchespublics, Maximilien, Mégalis Bretagne et sources régionales. Catégorie Maîtrise d\u0027œuvre explicitement couverte. Fiches synthétiques par marché, alertes email, téléchargement DCE en 1 clic. Module IA pour rédiger mémoires techniques (DC1, DC2).\n\n## Modèle économique\nPlan Veille Automatisée : 89€ HT/mois, engagement mensuel, 1 utilisateur, 1 activité de veille. Plan PME : prix à l\u0027utilisation, utilisateurs illimités, multi-activités. Plan Entreprise : sur devis. Module Réponse IA : crédits (tarif non affiché). Tarification principale publique et claire.\n\n## Pour qui\nArchitectes indépendants souhaitant une veille MOE automatisée avec aide à la rédaction des mémoires techniques. Le module IA de réponse est un différenciateur fort pour les profils peu habitués aux marchés publics ou manquant de temps pour rédiger les dossiers.\n\n## Points forts\nCatégorie MOE archi explicitement couverte. Module IA aide à la rédaction (mémoires, DC1, DC2) — unique dans le panel. Tarification principale publique (89€/mois). Engagement mensuel sans engagement annuel forcé.\n\n## Points de vigilance\nService en beta (2026) — fiabilité et couverture à confirmer sur la durée. Tarif module Réponse IA non affiché (crédits). 89€/mois représente un investissement significatif pour un indépendant en démarrage.",
|
||||||
|
"description_courte": "Veille marchés publics IA spécialisée MOE avec module rédaction mémoires DC1/DC2. 89€ HT/mois, engagement mensuel. En beta 2026 — prometteuse mais fiabilité à confirmer.",
|
||||||
|
"scoring": {
|
||||||
|
"remuneration": null,
|
||||||
|
"transparence": "✅",
|
||||||
|
"pratiques": null,
|
||||||
|
"ecologie": null,
|
||||||
|
"matching": "✅",
|
||||||
|
"tag_global": "recommande",
|
||||||
|
"justification_tag": "Scoring simplifié 2 axes (plateforme appels d\u0027offres publics). Tarification principale publique (89€/mois affiché sur /pricing, ✅ Transparence) et catégorie MOE explicitement couverte avec module IA rédaction unique dans le panel (✅ Matching). En beta — à surveiller."
|
||||||
|
},
|
||||||
|
"secteurs_servis": [
|
||||||
|
"mar-conseil",
|
||||||
|
"transversal"
|
||||||
|
],
|
||||||
|
"zone_geo": "france-entiere",
|
||||||
|
"cout_entree": "abonnement",
|
||||||
|
"date_creation_fiche": "2026-05-06",
|
||||||
|
"date_derniere_maj": "2026-05-06",
|
||||||
|
"source_donnees": [
|
||||||
|
"https://www.instao.fr/pricing"
|
||||||
|
],
|
||||||
|
"flag_validation_jules": false,
|
||||||
|
"commentaires": [
|
||||||
|
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "francemarches",
|
||||||
|
"nom": "FranceMarchés",
|
||||||
|
"url": "https://www.francemarches.com/appels-offre/maitrise-oeuvre",
|
||||||
|
"type": "appel-offre-public",
|
||||||
|
"description": "## Présentation\nPortail d\u0027appels d\u0027offres publics agrégant les publications de la presse régionale (Ouest-France, Voix du Nord, Est Républicain, Le Dauphiné, La Montagne...) en plus du BOAMP et des sources officielles. 3 016 AO maîtrise d\u0027œuvre en cours au 06/05/2026. 108 000+ abonnés. CGU accessibles sur /cgu.\n\n## Modèle économique\nAccès aux annonces entièrement gratuit. Alertes email gratuites. CGU disponibles et accessibles sur /cgu. Modèle économique basé sur le financement de la presse régionale partenaire. Aucun frais d\u0027inscription ni d\u0027abonnement pour les entreprises candidates.\n\n## Pour qui\nArchitectes cherchant des AO de maîtrise d\u0027œuvre sur tout le territoire, y compris les zones moins couvertes par BOAMP seul. La couverture presse régionale est un complément précieux pour les marchés locaux et régionaux.\n\n## Points forts\nCouverture presse régionale unique dans le panel — accès aux marchés locaux non publiés sur BOAMP uniquement. 3 016 AO MOE actifs confirmés. Entièrement gratuit. 108 000+ abonnés (forte adoption). CGU transparentes et accessibles.\n\n## Points de vigilance\nRésultats MOE très variés (logements, réhabilitation, aménagement urbain, infrastructures) — filtrage nécessaire pour isoler les missions archi résidentielle. Données issues partiellement de la presse régionale — moins uniformes que les sources officielles.",
|
||||||
|
"description_courte": "Portail gratuit agrégeant AO publics via presse régionale + BOAMP. 3 016 AO maîtrise d\u0027œuvre actifs. Couverture régionale unique — filtrage nécessaire parmi des résultats MOE variés.",
|
||||||
|
"scoring": {
|
||||||
|
"remuneration": null,
|
||||||
|
"transparence": "✅",
|
||||||
|
"pratiques": null,
|
||||||
|
"ecologie": null,
|
||||||
|
"matching": "✅",
|
||||||
|
"tag_global": "recommande",
|
||||||
|
"justification_tag": "Scoring simplifié 2 axes (plateforme appels d\u0027offres publics). Accès gratuit avec CGU accessibles sur /cgu (✅ Transparence) et 3 016 AO MOE actifs via couverture presse régionale unique dans le panel (✅ Matching)."
|
||||||
|
},
|
||||||
|
"secteurs_servis": [
|
||||||
|
"transversal"
|
||||||
|
],
|
||||||
|
"zone_geo": "france-entiere",
|
||||||
|
"cout_entree": "freemium",
|
||||||
|
"date_creation_fiche": "2026-05-06",
|
||||||
|
"date_derniere_maj": "2026-05-06",
|
||||||
|
"source_donnees": [
|
||||||
|
"https://www.francemarches.com/appels-offre/maitrise-oeuvre",
|
||||||
|
"https://www.francemarches.com/cgu"
|
||||||
|
],
|
||||||
|
"flag_validation_jules": false,
|
||||||
|
"commentaires": [
|
||||||
|
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
21073
public/data/reseaux-bifurcation.json
Normal file
21073
public/data/reseaux-bifurcation.json
Normal file
File diff suppressed because it is too large
Load Diff
39
server/api/admin/rag-info.get.ts
Normal file
39
server/api/admin/rag-info.get.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
/**
|
||||||
|
* GET /api/admin/rag-info
|
||||||
|
*
|
||||||
|
* Retourne le statut du système RAG (v1 + v2) pour la page /admin/rag-status
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { existsSync, readFileSync } from 'fs'
|
||||||
|
import { resolve } from 'path'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (_event) => {
|
||||||
|
// Statut V2 : compter les embeddings
|
||||||
|
let v2Count = 0
|
||||||
|
let v2Date: string | null = null
|
||||||
|
let v2Model: string | null = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Chercher depuis process.cwd() (racine du projet Nuxt)
|
||||||
|
const embPath = resolve(process.cwd(), 'server', 'data', 'embeddings-v2.json')
|
||||||
|
if (existsSync(embPath)) {
|
||||||
|
const data = JSON.parse(readFileSync(embPath, 'utf-8'))
|
||||||
|
v2Count = data.embeddings?.length ?? 0
|
||||||
|
v2Date = data.meta?.date ?? null
|
||||||
|
v2Model = data.meta?.model ?? null
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
console.warn('[rag-info] Erreur lecture embeddings-v2.json :', e?.message ?? e)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
v2_embeddings_count: v2Count,
|
||||||
|
v2_ready: v2Count > 0,
|
||||||
|
v2_model: v2Model ?? 'mistral-embed',
|
||||||
|
v2_generated_date: v2Date ?? null,
|
||||||
|
v1_enabled: process.env.RAG_V1_ENABLED !== 'false',
|
||||||
|
v1_deprecation_date: process.env.RAG_V1_DEPRECATION_DATE ?? 'non défini',
|
||||||
|
model_chat: 'mistral-small-latest',
|
||||||
|
setup_command: 'MISTRAL_API_KEY=xxx node scripts/vectorize-v2.js'
|
||||||
|
}
|
||||||
|
})
|
||||||
119
server/api/chatbot-pensees.post.ts
Normal file
119
server/api/chatbot-pensees.post.ts
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import type { H3Event } from 'h3'
|
||||||
|
import { checkRateLimitJson } from '~/server/utils/rateLimitJson'
|
||||||
|
|
||||||
|
interface ChatbotPenseesRequest {
|
||||||
|
query: string
|
||||||
|
mode?: 'hybrid' | 'local' | 'global' | 'naive' | 'mix'
|
||||||
|
corpus?: 'pensees' | 'projets' | 'both'
|
||||||
|
filter_couche?: 'fond' | 'forme' | 'structure' | null
|
||||||
|
filter_ecole?: string | null
|
||||||
|
history?: Array<{ role: 'user' | 'assistant'; content: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LightRAGQueryResponse {
|
||||||
|
response: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const SYSTEM_PREFACE_PENSEES = `Tu es un agent du RAG Pensées Écologiques, infrastructure militante du collectif trans-former.fr.
|
||||||
|
Tu réponds en t'appuyant STRICTEMENT sur le corpus ingéré (auteurs FRACAS Bonpote : écosocialisme, éco-anarchisme, écoféminismes, écologies décoloniales, technocritique, pensées du vivant, décroissance...).
|
||||||
|
|
||||||
|
Règles :
|
||||||
|
- Cite les sources (auteur, livre) à chaque assertion importante.
|
||||||
|
- Si la question dépasse le corpus, dis-le clairement. Pas d'hallucination.
|
||||||
|
- Ton politique direct, pas de neutralité fade.
|
||||||
|
- Réponse en français, dense, sans délayage.
|
||||||
|
- Distingue les positions selon les écoles quand elles divergent.`
|
||||||
|
|
||||||
|
const SYSTEM_PREFACE_PROJETS = `Tu es un agent du RAG Projets de Jules Nény (architecte, collectif trans-former.fr).
|
||||||
|
Tu réponds STRICTEMENT à partir des documents projet (fichiers butte-pinson__*.md et autres projets archi de Jules).
|
||||||
|
N'utilise PAS le corpus FRACAS Pensées Écologiques pour répondre, sauf si l'usager te le demande explicitement.
|
||||||
|
|
||||||
|
Règles :
|
||||||
|
- Cite les sources (nom de projet, document) à chaque assertion importante.
|
||||||
|
- Si la question dépasse le corpus projet, dis-le clairement. Pas d'hallucination.
|
||||||
|
- Ton praticien réflexif : 1ère personne quand pertinent, narration située.
|
||||||
|
- Réponse en français, dense, sans délayage.`
|
||||||
|
|
||||||
|
const SYSTEM_PREFACE_BOTH = `Tu es un agent du RAG croisé Pensées x Projets de Jules Nény (architecte militant, collectif trans-former.fr).
|
||||||
|
CENTRE TA RÉPONSE sur les documents PROJETS (fichiers butte-pinson__*.md et autres projets archi).
|
||||||
|
Mobilise le corpus FRACAS Pensées (autres fichiers) UNIQUEMENT pour éclairer théoriquement les partis pris des projets, jamais l'inverse.
|
||||||
|
|
||||||
|
Pondération attendue : ~70% ancrage projet concret, ~30% éclairage théorique FRACAS.
|
||||||
|
|
||||||
|
Règles :
|
||||||
|
- Cite les sources (auteur ou nom de projet, document) à chaque assertion.
|
||||||
|
- Si un thème n'est pas couvert par les projets, dis-le clairement avant d'éventuellement étendre au corpus Pensées.
|
||||||
|
- Pas d'hallucination, pas d'extrapolation hors corpus.
|
||||||
|
- Ton praticien militant : direct, pas neutre, ancré dans la pratique architecturale.
|
||||||
|
- Réponse en français, dense, sans délayage.`
|
||||||
|
|
||||||
|
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 allowed = checkRateLimitJson(ip, 'chatbot-pensees', 20)
|
||||||
|
if (!allowed) {
|
||||||
|
throw createError({ statusCode: 429, message: 'Limite de 20 questions par jour atteinte.' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 = body.query.trim()
|
||||||
|
const mode = body.mode || 'hybrid'
|
||||||
|
const corpus = body.corpus || 'both'
|
||||||
|
const ragUrl = (config.ragPeUrl as string) || 'http://localhost:9621'
|
||||||
|
|
||||||
|
// Préface adaptative selon corpus demandé
|
||||||
|
const systemPreface =
|
||||||
|
corpus === 'pensees'
|
||||||
|
? SYSTEM_PREFACE_PENSEES
|
||||||
|
: corpus === 'projets'
|
||||||
|
? SYSTEM_PREFACE_PROJETS
|
||||||
|
: SYSTEM_PREFACE_BOTH
|
||||||
|
|
||||||
|
// 3. Health guard — LightRAG down = erreur claire, pas de fallback hallucinatoire
|
||||||
|
try {
|
||||||
|
await $fetch(`${ragUrl}/health`, { timeout: 5000 })
|
||||||
|
} catch {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 503,
|
||||||
|
message: 'RAG indisponible pour l\'instant — réessaie dans quelques minutes.',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Call LightRAG VPS — préface système injectée dans la query
|
||||||
|
const ragQuery = `${systemPreface}\n\nQuestion : ${query}`
|
||||||
|
|
||||||
|
let ragResponse: LightRAGQueryResponse
|
||||||
|
try {
|
||||||
|
ragResponse = await $fetch<LightRAGQueryResponse>(`${ragUrl}/query`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: { query: ragQuery, mode },
|
||||||
|
timeout: 90000,
|
||||||
|
})
|
||||||
|
} catch (e: any) {
|
||||||
|
const status = e?.response?.status
|
||||||
|
if (status === 429) {
|
||||||
|
throw createError({ statusCode: 429, message: 'RAG saturé — réessaie dans quelques instants.' })
|
||||||
|
}
|
||||||
|
throw createError({ statusCode: 504, message: 'RAG en cours de processing — réessaie dans quelques secondes.' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Retour formaté
|
||||||
|
return {
|
||||||
|
response: ragResponse.response ?? '',
|
||||||
|
mode,
|
||||||
|
corpus,
|
||||||
|
filter: { couche: body.filter_couche ?? null, ecole: body.filter_ecole ?? null },
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
}
|
||||||
|
})
|
||||||
125
server/api/chatbot-reseaux.post.ts
Normal file
125
server/api/chatbot-reseaux.post.ts
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
/**
|
||||||
|
* POST /api/chatbot-reseaux
|
||||||
|
* Chatbot Réseaux AEP — Carte 2 "Réseaux de bifurcation"
|
||||||
|
* Keyword search sur reseaux-bifurcation.json + Mistral Small.
|
||||||
|
*/
|
||||||
|
// @ts-ignore — JSON import résolu par Rollup
|
||||||
|
import reseauxData from '../../public/data/reseaux-bifurcation.json'
|
||||||
|
import { checkRateLimitJson } from '~/server/utils/rateLimitJson'
|
||||||
|
|
||||||
|
interface Structure {
|
||||||
|
id: string
|
||||||
|
nom: string
|
||||||
|
url?: string
|
||||||
|
pays?: string
|
||||||
|
ville?: string
|
||||||
|
famille_principale?: string
|
||||||
|
hashtags?: string[]
|
||||||
|
description_courte?: string
|
||||||
|
description_longue?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const SYSTEM_PROMPT = `Tu es un conseiller expert au service des architectes engagés dans la transition écologique. Tu connais le réseau AEP (Architecture d'Écologie Politique) — une cartographie des collectifs, agences, initiatives et réseaux qui portent une vision alternative de l'architecture en France et en Europe.
|
||||||
|
|
||||||
|
Ton rôle : orienter l'architecte vers les structures les plus pertinentes pour sa situation, à partir des données ci-dessous.
|
||||||
|
|
||||||
|
RÈGLES :
|
||||||
|
1. Ne cite QUE des structures présentes dans le contexte ci-dessous.
|
||||||
|
2. Sois direct et engagé — c'est l'esprit AEP.
|
||||||
|
3. Réponse max 250 mots, en français.
|
||||||
|
4. Retourne UNIQUEMENT un JSON valide, sans texte avant ou après.
|
||||||
|
|
||||||
|
FORMAT :
|
||||||
|
{
|
||||||
|
"reponse_texte": "Ta réponse en prose, max 250 mots",
|
||||||
|
"fiches_recommandees": [
|
||||||
|
{ "id": "slug-id", "nom": "Nom structure", "explication": "Pourquoi en 1 phrase" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
STRUCTURES DISPONIBLES :
|
||||||
|
{{STRUCTURES_JSON}}`
|
||||||
|
|
||||||
|
function scoreStructure(s: Structure, keywords: string[]): number {
|
||||||
|
if (keywords.length === 0) return 1
|
||||||
|
const haystack = [s.nom, s.famille_principale, s.description_courte, s.description_longue, (s.hashtags ?? []).join(' '), s.ville, s.pays]
|
||||||
|
.filter(Boolean).join(' ').toLowerCase()
|
||||||
|
return keywords.reduce((score, kw) => score + (haystack.includes(kw) ? 1 : 0), 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractKeywords(q: string): string[] {
|
||||||
|
return q.toLowerCase().replace(/[^\w\sàâäéèêëîïôùûüç-]/g, ' ').split(/\s+/).filter(w => w.length >= 3).slice(0, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
|
||||||
|
const ip = getHeader(event, 'x-forwarded-for')?.split(',')[0].trim() || event.node.req.socket?.remoteAddress || '0.0.0.0'
|
||||||
|
const allowed = checkRateLimitJson(ip, 'chatbot-reseaux', 20)
|
||||||
|
if (!allowed) throw createError({ statusCode: 429, message: 'Limite de 20 questions par jour atteinte.' })
|
||||||
|
|
||||||
|
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 structures: Structure[] = ((reseauxData as any).structures ?? [])
|
||||||
|
const keywords = extractKeywords(question)
|
||||||
|
|
||||||
|
const top = structures
|
||||||
|
.map(s => ({ s, score: scoreStructure(s, keywords) }))
|
||||||
|
.sort((a, b) => b.score - a.score)
|
||||||
|
.slice(0, 20)
|
||||||
|
.map(x => x.s)
|
||||||
|
|
||||||
|
const context = top.map(s => ({
|
||||||
|
id: s.id,
|
||||||
|
nom: s.nom,
|
||||||
|
famille: s.famille_principale ?? '',
|
||||||
|
lieu: [s.ville, s.pays].filter(Boolean).join(', '),
|
||||||
|
tags: (s.hashtags ?? []).slice(0, 5).join(', '),
|
||||||
|
description: (s.description_courte ?? s.description_longue ?? '').slice(0, 200),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const systemPrompt = SYSTEM_PROMPT.replace('{{STRUCTURES_JSON}}', JSON.stringify(context, null, 0))
|
||||||
|
|
||||||
|
const mistralApiKey = config.mistralApiKey as string
|
||||||
|
if (!mistralApiKey) throw createError({ statusCode: 500, message: 'Clé API Mistral manquante.' })
|
||||||
|
|
||||||
|
let mistralRaw: string
|
||||||
|
try {
|
||||||
|
const res = await $fetch<{ choices: { message: { content: string } }[] }>(
|
||||||
|
'https://api.mistral.ai/v1/chat/completions',
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: `Bearer ${mistralApiKey}`, 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: 'mistral-small-latest',
|
||||||
|
temperature: 0.3,
|
||||||
|
max_tokens: 700,
|
||||||
|
response_format: { type: 'json_object' },
|
||||||
|
messages: [
|
||||||
|
{ role: 'system', content: systemPrompt },
|
||||||
|
{ role: 'user', content: question },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
mistralRaw = res.choices?.[0]?.message?.content ?? '{}'
|
||||||
|
} catch {
|
||||||
|
throw createError({ statusCode: 502, message: 'Erreur IA — réessaie dans quelques instants.' })
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(mistralRaw)
|
||||||
|
return {
|
||||||
|
reponse_texte: parsed.reponse_texte ?? "Je n'ai pas pu analyser ta demande.",
|
||||||
|
fiches_recommandees: (parsed.fiches_recommandees ?? []).map((r: any) => ({
|
||||||
|
id: r.id,
|
||||||
|
nom: r.nom ?? structures.find(s => s.id === r.id)?.nom ?? r.id,
|
||||||
|
explication: r.explication ?? '',
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return { reponse_texte: "Je n'ai pas pu analyser ta demande.", fiches_recommandees: [] }
|
||||||
|
}
|
||||||
|
})
|
||||||
136
server/api/chatbot-taff.post.ts
Normal file
136
server/api/chatbot-taff.post.ts
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
/**
|
||||||
|
* POST /api/chatbot-taff
|
||||||
|
* Chatbot d'aiguillage — Carte 3 "Trouver du taf"
|
||||||
|
* Lit plateformes-taff.json, appelle Mistral Small, retourne recommandations.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// @ts-ignore — JSON import résolu par Vite/Rollup
|
||||||
|
import taffData from '../../public/data/plateformes-taff.json'
|
||||||
|
import { checkRateLimitJson } from '~/server/utils/rateLimitJson'
|
||||||
|
|
||||||
|
interface PlateformeMinimal {
|
||||||
|
id: string
|
||||||
|
nom: string
|
||||||
|
type: string
|
||||||
|
description_courte: string
|
||||||
|
scoring: {
|
||||||
|
remuneration: string | null
|
||||||
|
transparence: string | null
|
||||||
|
pratiques: string | null
|
||||||
|
ecologie: string | null
|
||||||
|
matching: string | null
|
||||||
|
tag_global: string
|
||||||
|
justification_tag: string
|
||||||
|
}
|
||||||
|
secteurs_servis: string[]
|
||||||
|
cout_entree: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const SYSTEM_PROMPT = `Tu es un conseiller expert au service des architectes indépendants français. Tu connais toutes les plateformes de mise en relation architecte↔particulier et les agrégateurs d'appels d'offres publics référencés par AEP (Architecture d'Écologie Politique).
|
||||||
|
|
||||||
|
Ton rôle : aider l'architecte à choisir LA ou LES plateformes adaptées à sa situation, en t'appuyant exclusivement sur les données ci-dessous.
|
||||||
|
|
||||||
|
RÈGLES :
|
||||||
|
1. Ne recommande QUE des plateformes présentes dans le contexte ci-dessous.
|
||||||
|
2. Sois direct et opinionné — c'est ça la valeur d'AEP.
|
||||||
|
3. Si une plateforme est ❌ "À éviter", signale-le clairement.
|
||||||
|
4. Réponse max 250 mots, en français, ton conseiller pair.
|
||||||
|
5. Retourne UNIQUEMENT un JSON valide, sans texte avant ou après.
|
||||||
|
|
||||||
|
FORMAT :
|
||||||
|
{
|
||||||
|
"reponse_texte": "Ta réponse en prose, max 250 mots",
|
||||||
|
"plateformes_recommandees": [
|
||||||
|
{ "id": "slug-kebab", "nom": "Nom plateforme", "raison": "Pourquoi cette plateforme en 1 phrase" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
PLATEFORMES DISPONIBLES :
|
||||||
|
{{PLATEFORMES_JSON}}`
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
|
||||||
|
const ip =
|
||||||
|
getHeader(event, 'x-forwarded-for')?.split(',')[0].trim() ||
|
||||||
|
event.node.req.socket?.remoteAddress ||
|
||||||
|
'0.0.0.0'
|
||||||
|
|
||||||
|
const allowed = checkRateLimitJson(ip, 'chatbot-taff', 20)
|
||||||
|
if (!allowed) {
|
||||||
|
throw createError({ statusCode: 429, statusMessage: 'Limite de 20 questions par jour atteinte.' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await readBody(event)
|
||||||
|
const question: string = (body?.question ?? '').trim()
|
||||||
|
if (!question || question.length < 5) {
|
||||||
|
throw createError({ statusCode: 400, statusMessage: 'Question trop courte.' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Données bundlées statiquement à la compilation (import JSON)
|
||||||
|
const plateformes: PlateformeMinimal[] = ((taffData as any).plateformes ?? []).map((p: any) => ({
|
||||||
|
id: p.id,
|
||||||
|
nom: p.nom,
|
||||||
|
type: p.type,
|
||||||
|
description_courte: p.description_courte,
|
||||||
|
scoring: p.scoring,
|
||||||
|
secteurs_servis: p.secteurs_servis,
|
||||||
|
cout_entree: p.cout_entree,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const context = plateformes.map(p => ({
|
||||||
|
id: p.id,
|
||||||
|
nom: p.nom,
|
||||||
|
type: p.type === 'b2c-mise-en-relation' ? 'B2C' : 'Appels offres publics',
|
||||||
|
tag: p.scoring.tag_global,
|
||||||
|
resume: p.description_courte,
|
||||||
|
secteurs: p.secteurs_servis.join(', '),
|
||||||
|
cout: p.cout_entree,
|
||||||
|
justification: p.scoring.justification_tag,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const systemPrompt = SYSTEM_PROMPT.replace('{{PLATEFORMES_JSON}}', JSON.stringify(context, null, 0))
|
||||||
|
|
||||||
|
const mistralApiKey = config.mistralApiKey as string
|
||||||
|
if (!mistralApiKey) {
|
||||||
|
throw createError({ statusCode: 500, statusMessage: 'Clé API Mistral manquante.' })
|
||||||
|
}
|
||||||
|
|
||||||
|
let mistralRaw: string
|
||||||
|
try {
|
||||||
|
const res = await $fetch<{ choices: { message: { content: string } }[] }>(
|
||||||
|
'https://api.mistral.ai/v1/chat/completions',
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: `Bearer ${mistralApiKey}`, 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: 'mistral-small-latest',
|
||||||
|
temperature: 0.3,
|
||||||
|
max_tokens: 700,
|
||||||
|
response_format: { type: 'json_object' },
|
||||||
|
messages: [
|
||||||
|
{ role: 'system', content: systemPrompt },
|
||||||
|
{ role: 'user', content: question },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
mistralRaw = res.choices?.[0]?.message?.content ?? '{}'
|
||||||
|
} catch {
|
||||||
|
throw createError({ statusCode: 502, statusMessage: 'Erreur IA — réessaie dans quelques instants.' })
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(mistralRaw)
|
||||||
|
return {
|
||||||
|
reponse_texte: parsed.reponse_texte ?? "Je n'ai pas pu analyser ta demande.",
|
||||||
|
plateformes_recommandees: (parsed.plateformes_recommandees ?? []).map((r: any) => ({
|
||||||
|
id: r.id,
|
||||||
|
nom: r.nom ?? plateformes.find(p => p.id === r.id)?.nom ?? r.id,
|
||||||
|
raison: r.raison ?? '',
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return { reponse_texte: "Je n'ai pas pu analyser ta demande.", plateformes_recommandees: [] }
|
||||||
|
}
|
||||||
|
})
|
||||||
194
server/api/chatbot-v2.post.ts
Normal file
194
server/api/chatbot-v2.post.ts
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
/**
|
||||||
|
* POST /api/chatbot-v2
|
||||||
|
*
|
||||||
|
* Chatbot V2 - Embedding-based search sur structures bifurcation
|
||||||
|
* Coexiste avec /api/chatbot (keyword NocoDB) pendant la transition.
|
||||||
|
*
|
||||||
|
* SETUP AVANT DEPLOY :
|
||||||
|
* cd nav-carte && MISTRAL_API_KEY=xxx node scripts/vectorize-v2.js
|
||||||
|
* Coût estimé : ~0.10 EUR pour 120 fiches
|
||||||
|
*
|
||||||
|
* Flow :
|
||||||
|
* 1. Rate limit (réutilise checkRateLimitJson, 10 req/IP/jour)
|
||||||
|
* 2. Embed la query via Mistral Embed (mistral-embed)
|
||||||
|
* 3. Top-5 cosine similarity sur embeddings-v2.json
|
||||||
|
* 4. Si embeddings absents : réponse graceful (v2_ready: false)
|
||||||
|
* 5. Construit contexte RAG depuis les fiches candidates
|
||||||
|
* 6. Génère réponse Mistral Small (json_object)
|
||||||
|
* 7. Retourne { reponse_texte, fiches_recommandees, sources, v2_ready }
|
||||||
|
*
|
||||||
|
* Variables d'env :
|
||||||
|
* MISTRAL_API_KEY - Clé Mistral (partagée avec chatbot v1)
|
||||||
|
* RAG_V1_ENABLED - true/false (défaut: true) - coexistence pendant transition
|
||||||
|
* RAG_V1_DEPRECATION_DATE - Date prévue deprecation v1 (ex: 2026-05-18)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { checkRateLimitJson } from '~/server/utils/rateLimitJson'
|
||||||
|
import { loadEmbeddingsV2, topKSearch } from '~/server/utils/vectorSearch'
|
||||||
|
|
||||||
|
// ── System prompt V2 ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const SYSTEM_PROMPT_V2 = `Tu es un assistant pour la carte des réseaux de bifurcation en architecture (projet AEP).
|
||||||
|
Tu réponds aux questions sur les structures, les pratiques, les pensées écologiques.
|
||||||
|
|
||||||
|
Règles :
|
||||||
|
- Cite chaque structure par son nom exact et son fiche_id
|
||||||
|
- Indique la famille (1-5) entre parenthèses après chaque nom
|
||||||
|
- Reste sobre et descriptif - pas militant agressif
|
||||||
|
- Tirets longs interdits : utilise des - ou des ;
|
||||||
|
- Max 200 mots par réponse
|
||||||
|
- Si hors-scope (pas archi/habiter/écologie), redirige poliment vers la carte
|
||||||
|
- Retourne UNIQUEMENT un JSON valide, sans texte avant ou après
|
||||||
|
|
||||||
|
Familles :
|
||||||
|
1 - Réemploi et filières
|
||||||
|
2 - Frugalité et low-tech
|
||||||
|
3 - Architecture sociale et précarités
|
||||||
|
4 - Collectifs, écolieux et AMO
|
||||||
|
5 - Urbanisme de transition et territoires
|
||||||
|
|
||||||
|
FORMAT DE SORTIE :
|
||||||
|
{
|
||||||
|
"reponse_texte": "Ta réponse en prose (max 200 mots)",
|
||||||
|
"fiches_recommandees": [
|
||||||
|
{ "fiche_id": "f1-rotor", "nom": "Rotor", "explication": "1-2 phrases pourquoi cette fiche" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
CONTEXTE - Structures disponibles :
|
||||||
|
{{CONTEXTE_RAG}}`
|
||||||
|
|
||||||
|
// ── Handler ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
|
||||||
|
// 1. Rate limit
|
||||||
|
const ip =
|
||||||
|
getHeader(event, 'x-forwarded-for')?.split(',')[0].trim() ||
|
||||||
|
event.node.req.socket?.remoteAddress ||
|
||||||
|
'0.0.0.0'
|
||||||
|
|
||||||
|
const allowed = checkRateLimitJson(ip, 'chatbot-v2', 10)
|
||||||
|
if (!allowed) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 429,
|
||||||
|
statusMessage: 'Limite de 10 questions par jour atteinte.'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Validation body
|
||||||
|
const body = await readBody(event)
|
||||||
|
const question: string = (body?.question ?? '').trim()
|
||||||
|
|
||||||
|
if (!question || question.length < 3) {
|
||||||
|
throw createError({ statusCode: 400, statusMessage: 'Question trop courte.' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const mistralApiKey = config.mistralApiKey as string
|
||||||
|
if (!mistralApiKey) {
|
||||||
|
throw createError({ statusCode: 500, statusMessage: 'Clé API Mistral manquante.' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Charger embeddings V2 (lazy, cachés en mémoire)
|
||||||
|
const embeddingsV2 = loadEmbeddingsV2()
|
||||||
|
|
||||||
|
// Graceful fallback si le script vectorize-v2.js n'a pas encore été lancé
|
||||||
|
if (embeddingsV2.length === 0) {
|
||||||
|
return {
|
||||||
|
reponse_texte: "La base vectorielle V2 est en cours de préparation. Merci d'utiliser le chatbot classique en attendant.",
|
||||||
|
fiches_recommandees: [],
|
||||||
|
sources: [],
|
||||||
|
v2_ready: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Embed la query via Mistral Embed
|
||||||
|
let queryEmbedding: number[]
|
||||||
|
try {
|
||||||
|
const embedRes = await $fetch<{ data: { embedding: number[] }[] }>(
|
||||||
|
'https://api.mistral.ai/v1/embeddings',
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${mistralApiKey}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: 'mistral-embed',
|
||||||
|
inputs: [question]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
)
|
||||||
|
queryEmbedding = embedRes.data[0].embedding
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error('[chatbot-v2] Erreur embedding Mistral :', e?.message ?? e)
|
||||||
|
throw createError({ statusCode: 502, statusMessage: 'Erreur embedding Mistral.' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Top-5 cosine similarity
|
||||||
|
const v2Results = topKSearch(embeddingsV2, queryEmbedding, 5)
|
||||||
|
|
||||||
|
// 6. Contexte RAG
|
||||||
|
const candidatesContext = v2Results.map(r => ({
|
||||||
|
fiche_id: r.fiche_id,
|
||||||
|
nom: r.nom,
|
||||||
|
famille: r.famille,
|
||||||
|
hashtags: r.hashtags,
|
||||||
|
score: r.score,
|
||||||
|
preview: r.text_preview
|
||||||
|
}))
|
||||||
|
|
||||||
|
const contextStr = candidatesContext
|
||||||
|
.map(c => `[${c.fiche_id}] ${c.nom} (famille ${c.famille}, score: ${c.score.toFixed(2)})\n${c.preview}`)
|
||||||
|
.join('\n\n---\n\n')
|
||||||
|
|
||||||
|
const systemPrompt = SYSTEM_PROMPT_V2.replace('{{CONTEXTE_RAG}}', contextStr)
|
||||||
|
|
||||||
|
// 7. Mistral Small - génération réponse
|
||||||
|
let mistralRaw: string
|
||||||
|
try {
|
||||||
|
const mistralRes = await $fetch<{
|
||||||
|
choices: { message: { content: string } }[]
|
||||||
|
}>('https://api.mistral.ai/v1/chat/completions', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${mistralApiKey}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: 'mistral-small-latest',
|
||||||
|
temperature: 0.3,
|
||||||
|
max_tokens: 600,
|
||||||
|
response_format: { type: 'json_object' },
|
||||||
|
messages: [
|
||||||
|
{ role: 'system', content: systemPrompt },
|
||||||
|
{ role: 'user', content: question }
|
||||||
|
]
|
||||||
|
})
|
||||||
|
})
|
||||||
|
mistralRaw = mistralRes.choices?.[0]?.message?.content ?? '{}'
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error('[chatbot-v2] Erreur Mistral Small :', e?.message ?? e)
|
||||||
|
throw createError({ statusCode: 502, statusMessage: 'Erreur appel Mistral Small.' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8. Parse JSON
|
||||||
|
let parsed: { reponse_texte: string; fiches_recommandees: any[] }
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(mistralRaw)
|
||||||
|
if (!parsed.reponse_texte) throw new Error('reponse_texte absent')
|
||||||
|
} catch {
|
||||||
|
parsed = {
|
||||||
|
reponse_texte: "Impossible d'analyser la réponse.",
|
||||||
|
fiches_recommandees: []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
reponse_texte: parsed.reponse_texte,
|
||||||
|
fiches_recommandees: parsed.fiches_recommandees ?? [],
|
||||||
|
sources: candidatesContext,
|
||||||
|
v2_ready: true
|
||||||
|
}
|
||||||
|
})
|
||||||
46
server/api/codev/auth.post.ts
Normal file
46
server/api/codev/auth.post.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
const AuthSchema = z.object({
|
||||||
|
password: z.string().min(1).max(100),
|
||||||
|
})
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const body = await readBody(event)
|
||||||
|
const parsed = AuthSchema.safeParse(body)
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
throw createError({ statusCode: 422, statusMessage: 'Mot de passe invalide' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
const expected = config.codevPassword || 'merci'
|
||||||
|
|
||||||
|
const isAdmin = parsed.data.password.trim().toLowerCase() === (config.codevAdminPassword || 'admin2026').trim().toLowerCase()
|
||||||
|
const isUser = parsed.data.password.trim().toLowerCase() === expected.trim().toLowerCase()
|
||||||
|
|
||||||
|
if (!isAdmin && !isUser) {
|
||||||
|
throw createError({ statusCode: 401, statusMessage: 'Mauvais mot de passe' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cookie session (user + admin)
|
||||||
|
setCookie(event, 'codev_session', 'ok', {
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: 'lax',
|
||||||
|
secure: process.env.NODE_ENV === 'production',
|
||||||
|
maxAge: 60 * 60 * 24, // 24h
|
||||||
|
path: '/',
|
||||||
|
})
|
||||||
|
|
||||||
|
// Cookie admin si mot de passe admin
|
||||||
|
if (isAdmin) {
|
||||||
|
setCookie(event, 'codev_admin', 'ok', {
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: 'lax',
|
||||||
|
secure: process.env.NODE_ENV === 'production',
|
||||||
|
maxAge: 60 * 60 * 24, // 24h
|
||||||
|
path: '/',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return { status: 200, ok: true, admin: isAdmin }
|
||||||
|
})
|
||||||
31
server/api/codev/fiches.get.ts
Normal file
31
server/api/codev/fiches.get.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import type { CodevFiche } from '~/types/codev'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event): Promise<{ list: CodevFiche[] }> => {
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
const tableId = config.codevTableId
|
||||||
|
|
||||||
|
if (!tableId) {
|
||||||
|
throw createError({ statusCode: 500, message: 'codevTableId non configuré' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = `${config.nocodbUrl}/api/v2/tables/${tableId}/records?sort=created_at&limit=200`
|
||||||
|
|
||||||
|
const data: any = await $fetch(url, {
|
||||||
|
headers: { 'xc-token': config.nocodbToken },
|
||||||
|
}).catch(() => ({ list: [] }))
|
||||||
|
|
||||||
|
// Mapper chaque record NocoDB vers CodevFiche
|
||||||
|
const list: CodevFiche[] = (data?.list ?? []).map((r: any) => ({
|
||||||
|
id: r.Id ?? r.id,
|
||||||
|
nom: r.nom || '',
|
||||||
|
besoin: r.besoin || '',
|
||||||
|
offre: r.offre || '',
|
||||||
|
hashtags: (r.hashtags || '')
|
||||||
|
.split(',')
|
||||||
|
.map((h: string) => h.trim().toLowerCase().replace(/^#/, ''))
|
||||||
|
.filter(Boolean),
|
||||||
|
created_at: r.created_at || r.CreatedAt || new Date().toISOString(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
return { list }
|
||||||
|
})
|
||||||
63
server/api/codev/fiches.post.ts
Normal file
63
server/api/codev/fiches.post.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
const FicheSchema = z.object({
|
||||||
|
nom: z.string().min(2).max(50).trim(),
|
||||||
|
besoin: z.string().min(5).max(300).trim(),
|
||||||
|
offre: z.string().min(5).max(300).trim(),
|
||||||
|
hashtags: z.array(z.string().max(30)).max(3).default([]),
|
||||||
|
})
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const body = await readBody(event)
|
||||||
|
const parsed = FicheSchema.safeParse(body)
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 422,
|
||||||
|
statusMessage: 'Validation échouée',
|
||||||
|
data: parsed.error.flatten().fieldErrors,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
const tableId = config.codevTableId
|
||||||
|
const baseId = config.codevBaseId || 'pipilvsi7dibo80'
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
nom: parsed.data.nom,
|
||||||
|
besoin: parsed.data.besoin,
|
||||||
|
offre: parsed.data.offre,
|
||||||
|
hashtags: parsed.data.hashtags
|
||||||
|
.map((h) => h.trim().toLowerCase().replace(/^#/, ''))
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(0, 3)
|
||||||
|
.join(','),
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
}
|
||||||
|
|
||||||
|
// NocoDB v1 endpoint pour INSERT (cf. submit/index.post.ts pour le pattern)
|
||||||
|
const insertUrl = `${config.nocodbUrl}/api/v1/db/data/noco/${baseId}/${tableId}`
|
||||||
|
|
||||||
|
let inserted: any
|
||||||
|
try {
|
||||||
|
inserted = await $fetch(insertUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'xc-token': config.nocodbToken,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error('[codev/fiches.post] NocoDB insert error:', e?.message ?? e)
|
||||||
|
throw createError({
|
||||||
|
statusCode: 502,
|
||||||
|
statusMessage: 'Erreur serveur, réessaie',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 201,
|
||||||
|
id: inserted?.Id ?? inserted?.id ?? null,
|
||||||
|
}
|
||||||
|
})
|
||||||
25
server/api/codev/fiches/[id].delete.ts
Normal file
25
server/api/codev/fiches/[id].delete.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
// Vérif cookie admin
|
||||||
|
const adminCookie = getCookie(event, 'codev_admin')
|
||||||
|
if (adminCookie !== 'ok') {
|
||||||
|
throw createError({ statusCode: 403, statusMessage: 'Accès refusé' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
const tableId = config.codevTableId
|
||||||
|
const id = getRouterParam(event, 'id')
|
||||||
|
|
||||||
|
if (!tableId || !id) {
|
||||||
|
throw createError({ statusCode: 400, message: 'Parametre manquant' })
|
||||||
|
}
|
||||||
|
|
||||||
|
await $fetch(`${config.nocodbUrl}/api/v2/tables/${tableId}/records`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'xc-token': config.nocodbToken, 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ Id: Number(id) }),
|
||||||
|
}).catch(() => {
|
||||||
|
throw createError({ statusCode: 502, statusMessage: 'Erreur suppression' })
|
||||||
|
})
|
||||||
|
|
||||||
|
return { status: 200, ok: true }
|
||||||
|
})
|
||||||
34
server/api/codev/fiches/[id].get.ts
Normal file
34
server/api/codev/fiches/[id].get.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import type { CodevFiche } from '~/types/codev'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event): Promise<CodevFiche> => {
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
const tableId = config.codevTableId
|
||||||
|
const baseId = config.codevBaseId || 'pipilvsi7dibo80'
|
||||||
|
const id = getRouterParam(event, 'id')
|
||||||
|
|
||||||
|
if (!tableId || !id) {
|
||||||
|
throw createError({ statusCode: 400, message: 'Parametre manquant' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = `${config.nocodbUrl}/api/v1/db/data/noco/${baseId}/${tableId}/${id}`
|
||||||
|
|
||||||
|
const r: any = await $fetch(url, {
|
||||||
|
headers: { 'xc-token': config.nocodbToken },
|
||||||
|
}).catch(() => null)
|
||||||
|
|
||||||
|
if (!r) {
|
||||||
|
throw createError({ statusCode: 404, message: 'Fiche introuvable' })
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: r.Id ?? r.id,
|
||||||
|
nom: r.nom || '',
|
||||||
|
besoin: r.besoin || '',
|
||||||
|
offre: r.offre || '',
|
||||||
|
hashtags: (r.hashtags || '')
|
||||||
|
.split(',')
|
||||||
|
.map((h: string) => h.trim().toLowerCase().replace(/^#/, ''))
|
||||||
|
.filter(Boolean),
|
||||||
|
created_at: r.created_at || r.CreatedAt || '',
|
||||||
|
}
|
||||||
|
})
|
||||||
59
server/api/codev/fiches/[id].patch.ts
Normal file
59
server/api/codev/fiches/[id].patch.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
const PatchSchema = z.object({
|
||||||
|
nom: z.string().min(2).max(50).trim(),
|
||||||
|
besoin: z.string().min(5).max(300).trim(),
|
||||||
|
offre: z.string().min(5).max(300).trim(),
|
||||||
|
hashtags: z.array(z.string().max(30)).max(3).default([]),
|
||||||
|
})
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
const tableId = config.codevTableId
|
||||||
|
const baseId = config.codevBaseId || 'pipilvsi7dibo80'
|
||||||
|
const id = getRouterParam(event, 'id')
|
||||||
|
const body = await readBody(event)
|
||||||
|
|
||||||
|
if (!tableId || !id) {
|
||||||
|
throw createError({ statusCode: 400, message: 'Parametre manquant' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = PatchSchema.safeParse(body)
|
||||||
|
if (!parsed.success) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 422,
|
||||||
|
statusMessage: 'Validation echouee',
|
||||||
|
data: parsed.error.flatten().fieldErrors,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
nom: parsed.data.nom,
|
||||||
|
besoin: parsed.data.besoin,
|
||||||
|
offre: parsed.data.offre,
|
||||||
|
hashtags: parsed.data.hashtags
|
||||||
|
.map((h) => h.trim().toLowerCase().replace(/^#/, ''))
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(0, 3)
|
||||||
|
.join(','),
|
||||||
|
}
|
||||||
|
|
||||||
|
// NocoDB v1 PATCH par Id
|
||||||
|
const url = `${config.nocodbUrl}/api/v1/db/data/noco/${baseId}/${tableId}/${id}`
|
||||||
|
|
||||||
|
try {
|
||||||
|
await $fetch(url, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {
|
||||||
|
'xc-token': config.nocodbToken,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error('[codev/fiches.patch] NocoDB patch error:', e?.message ?? e)
|
||||||
|
throw createError({ statusCode: 502, statusMessage: 'Erreur serveur' })
|
||||||
|
}
|
||||||
|
|
||||||
|
return { status: 200, ok: true }
|
||||||
|
})
|
||||||
5
server/api/codev/me.get.ts
Normal file
5
server/api/codev/me.get.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export default defineEventHandler((event) => {
|
||||||
|
const admin = getCookie(event, 'codev_admin') === 'ok'
|
||||||
|
const session = getCookie(event, 'codev_session') === 'ok'
|
||||||
|
return { admin, session }
|
||||||
|
})
|
||||||
9
server/api/plateformes-taff.get.ts
Normal file
9
server/api/plateformes-taff.get.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
/**
|
||||||
|
* GET /api/plateformes-taff
|
||||||
|
* Retourne les données TAFF — JSON importé statiquement (bundlé par Rollup).
|
||||||
|
* Utilisé par le chatbot-taff en interne et potentiellement par le front.
|
||||||
|
*/
|
||||||
|
// @ts-ignore — JSON import résolu par Vite/Rollup
|
||||||
|
import data from '../../public/data/plateformes-taff.json'
|
||||||
|
|
||||||
|
export default defineEventHandler(() => data)
|
||||||
21
server/middleware/codev-auth.ts
Normal file
21
server/middleware/codev-auth.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
// Middleware server Nuxt — protection des routes /codev/fiche et /codev/carto
|
||||||
|
// Laisse passer /codev (lock screen), /codev/demo et toutes les routes /api/*
|
||||||
|
|
||||||
|
export default defineEventHandler((event) => {
|
||||||
|
const url = getRequestURL(event)
|
||||||
|
const path = url.pathname
|
||||||
|
|
||||||
|
// Seulement les routes sous /codev/
|
||||||
|
if (!path.startsWith('/codev/')) return
|
||||||
|
|
||||||
|
// Routes publiques : /codev/demo et /codev/qr (et sous-routes éventuelles)
|
||||||
|
if (path === '/codev/demo' || path.startsWith('/codev/demo/')) return
|
||||||
|
if (path === '/codev/qr' || path.startsWith('/codev/qr/')) return
|
||||||
|
|
||||||
|
// Vérification cookie
|
||||||
|
const session = getCookie(event, 'codev_session')
|
||||||
|
if (session === 'ok') return
|
||||||
|
|
||||||
|
// Non authentifié -> redirect vers /codev (lock screen)
|
||||||
|
return sendRedirect(event, '/codev', 302)
|
||||||
|
})
|
||||||
96
server/utils/vectorSearch.ts
Normal file
96
server/utils/vectorSearch.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
/**
|
||||||
|
* Recherche vectorielle sur les embeddings V2
|
||||||
|
* Cosine similarity + top-K
|
||||||
|
*
|
||||||
|
* Utilisé par : server/api/chatbot-v2.post.ts
|
||||||
|
* Données : server/data/embeddings-v2.json (généré par scripts/vectorize-v2.js)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { readFileSync, existsSync } from 'fs'
|
||||||
|
import { fileURLToPath } from 'url'
|
||||||
|
import { resolve, dirname } from 'path'
|
||||||
|
|
||||||
|
// ── Types ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface EmbeddingEntry {
|
||||||
|
fiche_id: string
|
||||||
|
nom: string
|
||||||
|
famille: number
|
||||||
|
hashtags: string[]
|
||||||
|
embedding: number[]
|
||||||
|
text_preview: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SearchResult {
|
||||||
|
fiche_id: string
|
||||||
|
nom: string
|
||||||
|
famille: number
|
||||||
|
hashtags: string[]
|
||||||
|
score: number
|
||||||
|
text_preview: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Cosine similarity ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function cosineSimilarity(a: number[], b: number[]): number {
|
||||||
|
if (a.length !== b.length) return 0
|
||||||
|
let dot = 0, normA = 0, normB = 0
|
||||||
|
for (let i = 0; i < a.length; i++) {
|
||||||
|
dot += a[i] * b[i]
|
||||||
|
normA += a[i] * a[i]
|
||||||
|
normB += b[i] * b[i]
|
||||||
|
}
|
||||||
|
const denom = Math.sqrt(normA) * Math.sqrt(normB)
|
||||||
|
return denom === 0 ? 0 : dot / denom
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Top-K search ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function topKSearch(
|
||||||
|
embeddings: EmbeddingEntry[],
|
||||||
|
queryEmbedding: number[],
|
||||||
|
k: number = 5
|
||||||
|
): SearchResult[] {
|
||||||
|
return embeddings
|
||||||
|
.map(e => ({
|
||||||
|
fiche_id: e.fiche_id,
|
||||||
|
nom: e.nom,
|
||||||
|
famille: e.famille,
|
||||||
|
hashtags: e.hashtags,
|
||||||
|
score: cosineSimilarity(e.embedding, queryEmbedding),
|
||||||
|
text_preview: e.text_preview
|
||||||
|
}))
|
||||||
|
.sort((a, b) => b.score - a.score)
|
||||||
|
.slice(0, k)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Chargement lazy des embeddings (cache module-level) ────────────────────────
|
||||||
|
|
||||||
|
let _embeddingsV2: EmbeddingEntry[] | null = null
|
||||||
|
|
||||||
|
export function loadEmbeddingsV2(): EmbeddingEntry[] {
|
||||||
|
if (_embeddingsV2 !== null) return _embeddingsV2
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Résolution du chemin depuis server/utils/ vers server/data/
|
||||||
|
const currentDir = dirname(fileURLToPath(import.meta.url))
|
||||||
|
const embPath = resolve(currentDir, '..', 'data', 'embeddings-v2.json')
|
||||||
|
|
||||||
|
if (!existsSync(embPath)) {
|
||||||
|
console.warn('[vectorSearch] embeddings-v2.json absent - V2 vector search désactivé')
|
||||||
|
console.warn('[vectorSearch] Lancer : MISTRAL_API_KEY=xxx node scripts/vectorize-v2.js')
|
||||||
|
_embeddingsV2 = []
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = readFileSync(embPath, 'utf-8')
|
||||||
|
const data = JSON.parse(raw)
|
||||||
|
_embeddingsV2 = data.embeddings ?? []
|
||||||
|
console.log(`[vectorSearch] ${_embeddingsV2!.length} embeddings V2 chargés (${data.meta?.model ?? 'unknown'})`)
|
||||||
|
return _embeddingsV2!
|
||||||
|
} catch (e: any) {
|
||||||
|
console.warn('[vectorSearch] Erreur chargement embeddings-v2.json :', e?.message ?? e)
|
||||||
|
_embeddingsV2 = []
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
18
types/codev.ts
Normal file
18
types/codev.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
export interface CodevFiche {
|
||||||
|
id: number
|
||||||
|
nom: string
|
||||||
|
besoin: string
|
||||||
|
offre: string
|
||||||
|
hashtags: string[] // parsé depuis CSV NocoDB
|
||||||
|
created_at: string // ISO
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CodevMatch {
|
||||||
|
fromId: number
|
||||||
|
toId: number
|
||||||
|
score: number // 0-1
|
||||||
|
mode: 'solution' | 'alliance' | 'surprise'
|
||||||
|
// solution : fromId.besoin matche toId.offre (orienté)
|
||||||
|
// alliance : symétrique sur besoin
|
||||||
|
// surprise : symétrique sur offre
|
||||||
|
}
|
||||||
106
types/plateforme-taff.ts
Normal file
106
types/plateforme-taff.ts
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
/**
|
||||||
|
* Types V1 — Carte 3 AEP "Trouver du taf en archi"
|
||||||
|
* Source : public/data/plateformes-taff.json
|
||||||
|
* Spec figée : 0 INBOX/PROMPTS/cascade-megaboum/MP-TAFF-app-trouver-du-taf.md
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type AxeScore = "✅" | "⚠️" | "❌";
|
||||||
|
|
||||||
|
export type TagGlobal = "recommande" | "sous-reserve" | "a-eviter";
|
||||||
|
|
||||||
|
export type Secteur =
|
||||||
|
| "renovation"
|
||||||
|
| "construction-neuve"
|
||||||
|
| "urbanisme"
|
||||||
|
| "architecture-interieure"
|
||||||
|
| "paysage"
|
||||||
|
| "mar-conseil"
|
||||||
|
| "transversal";
|
||||||
|
|
||||||
|
export type TypePlateforme =
|
||||||
|
| "b2c-mise-en-relation" // V1 cible (Travaux.com, Habitatpresto, etc.)
|
||||||
|
| "appel-offre-public" // V1 onglet bonus light
|
||||||
|
| "communaute-pro"; // backlog V2 (Welow, etc.)
|
||||||
|
|
||||||
|
export type CoutEntree = "gratuit" | "freemium" | "abonnement" | "lead-paye" | "commission";
|
||||||
|
|
||||||
|
export type ZoneGeo = "france-entiere" | "regional" | string;
|
||||||
|
|
||||||
|
export interface ScoringTaff {
|
||||||
|
// Pour b2c-mise-en-relation : tous les 5 axes sont remplis.
|
||||||
|
// Pour appel-offre-public : seuls transparence + matching sont remplis,
|
||||||
|
// les 3 autres sont null (scoring simplifié décision F du MP).
|
||||||
|
remuneration: AxeScore | null;
|
||||||
|
transparence: AxeScore;
|
||||||
|
pratiques: AxeScore | null;
|
||||||
|
ecologie: AxeScore | null;
|
||||||
|
matching: AxeScore;
|
||||||
|
tag_global: TagGlobal;
|
||||||
|
justification_tag: string; // 1-2 phrases pourquoi ce tag
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Commentaire {
|
||||||
|
id: string;
|
||||||
|
date: string;
|
||||||
|
auteur_pseudo: string;
|
||||||
|
contenu: string;
|
||||||
|
modere: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlateformeTaff {
|
||||||
|
id: string; // slug-kebab
|
||||||
|
nom: string;
|
||||||
|
url: string;
|
||||||
|
type: TypePlateforme;
|
||||||
|
description: string; // IA 250 mots (5 sections fixes ≤50 mots)
|
||||||
|
description_courte: string; // IA 30 mots (carte preview)
|
||||||
|
|
||||||
|
scoring: ScoringTaff;
|
||||||
|
|
||||||
|
secteurs_servis: Secteur[];
|
||||||
|
zone_geo: ZoneGeo; // si "regional", précise zones
|
||||||
|
cout_entree: CoutEntree;
|
||||||
|
|
||||||
|
date_creation_fiche: string; // ISO
|
||||||
|
date_derniere_maj: string; // ISO — pour pipeline trimestriel
|
||||||
|
source_donnees: string[]; // URLs scrapées
|
||||||
|
flag_validation_jules: boolean; // true si tag ❌ validé manuellement
|
||||||
|
|
||||||
|
commentaires?: Commentaire[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlateformesTaffData {
|
||||||
|
meta: {
|
||||||
|
version: string;
|
||||||
|
date_generation: string; // ISO
|
||||||
|
total: number;
|
||||||
|
repartition: {
|
||||||
|
recommande: number;
|
||||||
|
sous_reserve: number;
|
||||||
|
a_eviter: number;
|
||||||
|
};
|
||||||
|
repartition_type: {
|
||||||
|
b2c: number;
|
||||||
|
appel_offre_public: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
plateformes: PlateformeTaff[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const FAMILLES_SECTEUR: { id: Secteur; label: string; color: string }[] = [
|
||||||
|
{ id: "renovation", label: "Rénovation", color: "#5a7a4a" },
|
||||||
|
{ id: "construction-neuve", label: "Construction neuve", color: "#3d6a8c" },
|
||||||
|
{ id: "urbanisme", label: "Urbanisme", color: "#6b3fa0" },
|
||||||
|
{ id: "architecture-interieure", label: "Archi intérieure", color: "#a85d3e" },
|
||||||
|
{ id: "paysage", label: "Paysage", color: "#5a7a4a" },
|
||||||
|
{ id: "mar-conseil", label: "MAR / Conseil", color: "#c4a472" },
|
||||||
|
{ id: "transversal", label: "Transversal", color: "#888888" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const TAG_LABELS: Record<TagGlobal, { label: string; emoji: string; color: string }> = {
|
||||||
|
"recommande": { label: "Recommandé AEP", emoji: "✅", color: "#5a7a4a" },
|
||||||
|
"sous-reserve": { label: "Sous réserve", emoji: "⚠️", color: "#c4a472" },
|
||||||
|
"a-eviter": { label: "À éviter", emoji: "❌", color: "#a85d3e" },
|
||||||
|
};
|
||||||
91
types/structure-v2.ts
Normal file
91
types/structure-v2.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
/**
|
||||||
|
* Types V2 - Carte des réseaux de bifurcation
|
||||||
|
* Source : public/data/reseaux-bifurcation.json
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface StructureV2 {
|
||||||
|
id: string
|
||||||
|
nom: string
|
||||||
|
url: string
|
||||||
|
pays: string
|
||||||
|
ville: string
|
||||||
|
famille_principale: 1 | 2 | 3 | 4 | 5
|
||||||
|
familles_secondaires?: number[]
|
||||||
|
hashtags: string[]
|
||||||
|
type_principal: string
|
||||||
|
badges: {
|
||||||
|
centre_ressources: boolean
|
||||||
|
mouvement_manifeste: boolean
|
||||||
|
contre_pouvoir_spatial: boolean
|
||||||
|
f6_recherche_politique: boolean
|
||||||
|
}
|
||||||
|
description_courte: string
|
||||||
|
description_longue: string
|
||||||
|
pensees: { id: string; label: string; confiance: string }[]
|
||||||
|
sources: { type: string; titre: string; url: string }[]
|
||||||
|
already_in_v1: boolean
|
||||||
|
eligible_v2: boolean
|
||||||
|
// Geocoords (ajoutés par géocodage - peut être null)
|
||||||
|
latitude?: number | null
|
||||||
|
longitude?: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReseauxBifurcationData {
|
||||||
|
version: string
|
||||||
|
meta: {
|
||||||
|
total_structures: number
|
||||||
|
total_projets_emblematiques: number
|
||||||
|
total_edges_graphe: number
|
||||||
|
familles: { id: number; label: string; color: string }[]
|
||||||
|
hashtags_officiels: string[]
|
||||||
|
}
|
||||||
|
structures: StructureV2[]
|
||||||
|
projets: ProjetEmblematique[]
|
||||||
|
graphe: { edges: GrapheEdge[] }
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjetEmblematique {
|
||||||
|
id: string
|
||||||
|
nom: string
|
||||||
|
structure_parent: string
|
||||||
|
annee?: number
|
||||||
|
lieu?: string
|
||||||
|
geocoords?: { lat: number; lng: number } | null
|
||||||
|
description: string
|
||||||
|
url?: string | null
|
||||||
|
tags: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GrapheEdge {
|
||||||
|
source: string
|
||||||
|
target: string
|
||||||
|
types: string[]
|
||||||
|
score: number
|
||||||
|
evidence: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mapping StructureV2 vers le format attendu par NavMap (interface Org)
|
||||||
|
// NavMap attend { Id, latitude, longitude, nom, ... }
|
||||||
|
export function structureToMapOrg(s: StructureV2, index: number): {
|
||||||
|
Id: number
|
||||||
|
nom: string
|
||||||
|
latitude?: number | null
|
||||||
|
longitude?: number | null
|
||||||
|
prioritaire?: boolean
|
||||||
|
famille_principale?: number
|
||||||
|
hashtags?: string[]
|
||||||
|
type_principal?: string
|
||||||
|
description_courte?: string
|
||||||
|
} {
|
||||||
|
return {
|
||||||
|
Id: index,
|
||||||
|
nom: s.nom,
|
||||||
|
latitude: s.latitude,
|
||||||
|
longitude: s.longitude,
|
||||||
|
prioritaire: s.badges?.centre_ressources || s.badges?.mouvement_manifeste || s.badges?.contre_pouvoir_spatial,
|
||||||
|
famille_principale: s.famille_principale,
|
||||||
|
hashtags: s.hashtags,
|
||||||
|
type_principal: s.type_principal,
|
||||||
|
description_courte: s.description_courte,
|
||||||
|
}
|
||||||
|
}
|
||||||
106
utils/codev/matching.ts
Normal file
106
utils/codev/matching.ts
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import type { CodevFiche, CodevMatch } from '~/types/codev'
|
||||||
|
|
||||||
|
const STOP_WORDS_FR = new Set([
|
||||||
|
'le', 'la', 'les', 'un', 'une', 'des', 'de', 'du', 'au', 'aux',
|
||||||
|
'et', 'ou', 'mais', 'donc', 'car', 'ni', 'or',
|
||||||
|
'a', 'en', 'pour', 'par', 'sur', 'avec', 'sans', 'dans', 'sous',
|
||||||
|
'je', 'tu', 'il', 'elle', 'on', 'nous', 'vous', 'ils', 'elles',
|
||||||
|
'mon', 'ma', 'mes', 'ton', 'ta', 'tes', 'son', 'sa', 'ses',
|
||||||
|
'notre', 'nos', 'votre', 'vos', 'leur', 'leurs',
|
||||||
|
'ce', 'cet', 'cette', 'ces', 'qui', 'que', 'quoi', 'dont',
|
||||||
|
'est', 'sont', 'etre', 'ai', 'as', 'avoir',
|
||||||
|
'pas', 'plus', 'moins', 'tres', 'aussi', 'bien', 'tout', 'tous',
|
||||||
|
'me', 'te', 'se', 'lui', 'leur', 'y',
|
||||||
|
])
|
||||||
|
|
||||||
|
function tokenize(text: string): Set<string> {
|
||||||
|
if (!text) return new Set()
|
||||||
|
const tokens = text
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[.,;:!?()'"\-/]/g, ' ')
|
||||||
|
.split(/\s+/)
|
||||||
|
.filter((t) => t.length >= 3 && !STOP_WORDS_FR.has(t))
|
||||||
|
return new Set(tokens)
|
||||||
|
}
|
||||||
|
|
||||||
|
function jaccard(a: Set<string>, b: Set<string>): number {
|
||||||
|
if (a.size === 0 || b.size === 0) return 0
|
||||||
|
let inter = 0
|
||||||
|
for (const x of a) if (b.has(x)) inter++
|
||||||
|
const union = a.size + b.size - inter
|
||||||
|
return union === 0 ? 0 : inter / union
|
||||||
|
}
|
||||||
|
|
||||||
|
function score(textA: string, hashtagsA: string[], textB: string, hashtagsB: string[]): number {
|
||||||
|
const tagsA = new Set(hashtagsA.map((h) => h.toLowerCase()))
|
||||||
|
const tagsB = new Set(hashtagsB.map((h) => h.toLowerCase()))
|
||||||
|
|
||||||
|
if (tagsA.size > 0 && tagsB.size > 0) {
|
||||||
|
return jaccard(tagsA, tagsB)
|
||||||
|
}
|
||||||
|
return jaccard(tokenize(textA), tokenize(textB))
|
||||||
|
}
|
||||||
|
|
||||||
|
// scoreDirect tokenise TOUJOURS les textes, ignore les hashtags
|
||||||
|
// Utilise pour matchSolution : besoin vs offre doivent etre compares par leur contenu reel
|
||||||
|
function scoreDirect(textA: string, textB: string): number {
|
||||||
|
return jaccard(tokenize(textA), tokenize(textB))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function matchSolution(fiches: CodevFiche[], threshold = 0.18): CodevMatch[] {
|
||||||
|
const matches: CodevMatch[] = []
|
||||||
|
for (const a of fiches) {
|
||||||
|
for (const b of fiches) {
|
||||||
|
if (a.id === b.id) continue
|
||||||
|
// Solution : on compare le TEXTE besoin de A avec le TEXTE offre de B
|
||||||
|
// On ignore les hashtags pour differencier besoin et offre
|
||||||
|
const s = scoreDirect(a.besoin, b.offre)
|
||||||
|
if (s >= threshold) {
|
||||||
|
matches.push({ fromId: a.id, toId: b.id, score: s, mode: 'solution' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return matches
|
||||||
|
}
|
||||||
|
|
||||||
|
export function matchAlliance(fiches: CodevFiche[], threshold = 0.25): CodevMatch[] {
|
||||||
|
const matches: CodevMatch[] = []
|
||||||
|
for (let i = 0; i < fiches.length; i++) {
|
||||||
|
for (let j = i + 1; j < fiches.length; j++) {
|
||||||
|
const a = fiches[i], b = fiches[j]
|
||||||
|
// Alliance : besoins similaires — on compare hashtags si presents, sinon textes
|
||||||
|
const s = score(a.besoin, a.hashtags, b.besoin, b.hashtags)
|
||||||
|
if (s >= threshold) {
|
||||||
|
matches.push({ fromId: a.id, toId: b.id, score: s, mode: 'alliance' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return matches
|
||||||
|
}
|
||||||
|
|
||||||
|
export function matchSurprise(fiches: CodevFiche[], threshold = 0.25): CodevMatch[] {
|
||||||
|
const matches: CodevMatch[] = []
|
||||||
|
for (let i = 0; i < fiches.length; i++) {
|
||||||
|
for (let j = i + 1; j < fiches.length; j++) {
|
||||||
|
const a = fiches[i], b = fiches[j]
|
||||||
|
// Surprise : offres similaires
|
||||||
|
const s = score(a.offre, a.hashtags, b.offre, b.hashtags)
|
||||||
|
if (s >= threshold) {
|
||||||
|
matches.push({ fromId: a.id, toId: b.id, score: s, mode: 'surprise' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return matches
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeMatches(
|
||||||
|
fiches: CodevFiche[],
|
||||||
|
mode: 'solution' | 'alliance' | 'surprise',
|
||||||
|
threshold?: number,
|
||||||
|
): CodevMatch[] {
|
||||||
|
switch (mode) {
|
||||||
|
case 'solution': return matchSolution(fiches, threshold)
|
||||||
|
case 'alliance': return matchAlliance(fiches, threshold)
|
||||||
|
case 'surprise': return matchSurprise(fiches, threshold)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user