Compare commits
10 Commits
c6295ea228
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd8fe9e258 | ||
|
|
ea7c8cc91e | ||
|
|
538c490e76 | ||
|
|
d584d04e3d | ||
|
|
bd95c0f00d | ||
|
|
db8f614928 | ||
|
|
59cb81a055 | ||
|
|
91e3466ec6 | ||
|
|
cb75889231 | ||
|
|
422f45116f |
@@ -11,56 +11,6 @@ Journal technique de la V2. Décisions, anomalies, points bloquants, TODOs.
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-08 — Fix mobile + chatbot prod (cause racine résolue)
|
||||
|
||||
**Commits :** session loggée sur main (pushé sur gitea)
|
||||
**Pattern :** pilote direct, 2 batches successifs, ~3h, 11 fichiers
|
||||
|
||||
### Cause racine bug "chatbot Carte 1 == Carte 2"
|
||||
|
||||
`/api/chatbot-reseaux` était **404 en prod** (jamais déployé) — explique pourquoi 5 cycles de fix précédents (ChatbotReseaux.vue prop endpoint, useRoute fallback, useMarkdown direct, etc.) n'ont rien donné : le code source était correct depuis le début. Le rebuild + redeploy de cette session résout le bug.
|
||||
|
||||
**Verif :** `curl -s -X POST https://aep.trans-former.fr/api/chatbot-reseaux` → 200 + réponse distincte de `/api/chatbot`.
|
||||
|
||||
### Batch 1 — fixes mobile principaux
|
||||
|
||||
- Hamburger app.vue : ajout Jobs + Manifeste + Soutenir, ré-ordonnancement (Manifeste dans 2e groupe avec À propos/Soutenir/Signaler)
|
||||
- BandeauBas.vue : FAB cœur jaune mobile retiré (Soutenir migré dans hamburger via lien Liberapay direct)
|
||||
- agences.vue mobile : 3e onglet "Graphe" ajouté + masquage MobileSheet en mode graphe (canvas fullscreen)
|
||||
- a-propos.vue : section 1 "Mission" retirée (devient pop-up Carte 1) + `overflow-x: hidden` sur `.apropos-page` + retrait `white-space: nowrap` problématique sur `.badge-detail`
|
||||
- pages/manifeste.vue : nouvelle page (texte version `manifeste-page-carto-V1.md`, sans le diagramme ASCII pour V1 web)
|
||||
- components/MissionPopup.vue : nouveau composant générique (props `title`, `ctaLabel`, `storageKey`, slot pour contenu, `:slotted()` pour styles)
|
||||
- index.vue : intégration MissionPopup + bouton (i) `position:fixed` bottom-left + auto-show 1ère visite via `localStorage.aep_mission_seen`
|
||||
- trouver-du-taf.vue : toggle "Filtres [N] [chevron]" mobile-only (`@media max-width: 767px`) avec `taff-filters-collapsible` max-height transition
|
||||
- FicheModal.vue + FicheModalV2.vue : sur mobile `top: 76px` + `max-height: calc(100dvh - 92px)` au lieu de `top: 50% translate(-50%, -50%)` + `max-height: 90vh` qui mordait sur le header
|
||||
|
||||
### Batch 2 — pop-up Carte 2, logo, intro Jobs, labels graphe
|
||||
|
||||
- agences.vue : pop-up Réseaux AEP avec MissionPopup (storageKey `aep_reseaux_seen`, ctaLabel "Explorer les 120 réseaux") + bouton (i) flottant
|
||||
- app.vue logo header : badge AEP + 2 spans `logo-line-1` ("Architecture") / `logo-line-2` ("d'Écologie Politique") avec font-size responsive (0.7rem mobile → 0.85rem ≥1024)
|
||||
- trouver-du-taf.vue : `<details class="taff-pedago" open>` avec 3 blocs (deux onglets, trois étiquettes, cinq axes) + onglet "Plateformes B2C" → "Pour archi indépendants"
|
||||
- GraphView.vue : `d3NodeSelection.filter(type==='structure').append('text')` avec class `graph-struct-label`, `dy: -(d.r + 5)`, font-size 9.5px, halo via `paint-order: stroke; stroke: var(--nav-bg)` (style global non-scoped pour piercer D3)
|
||||
|
||||
### Bug d'opération à retenir
|
||||
|
||||
Lors du 1er déploiement batch 2, `bash deploy.sh` semblait OK (HTTP 200) mais le HTML en prod ne contenait pas les modifs. **Cause** : Dropbox sync a effacé `.output/` entre `npm run build` et le tar SCP — le tar a uploadé un `.output` quasi-vide. Solution : 2e cycle clean (`Remove-Item .nuxt/dist + .output`) + rebuild + redeploy avec `yes y |` (skip confirm interactif `.env diff`).
|
||||
|
||||
**Réflexe à intégrer** : après build, vérifier `grep -o "<un-fragment-de-modif>" .output/public/_nuxt/*.js | head` AVANT le deploy. Si 0 match → ne pas deploy, rebuild.
|
||||
|
||||
### Bug de communication à retenir
|
||||
|
||||
Jules a signalé "le logo n'a pas marché", "B2C pas renommé", "hamburger pas modifié" alors que le HTML en prod contenait bien les modifs (vérifié curl avec `?nc=$(date +%s)`). **Cause** : cache navigateur / service worker Nuxt. Réflexe à mettre en place pour /done de toute session web : si Jules dit "ça n'apparaît pas", vérifier curl en bypass cache AVANT de chercher un bug. Si match curl → demander hard refresh (Ctrl+Shift+R).
|
||||
|
||||
### Reste à faire (batch 3)
|
||||
|
||||
Voir `0 INBOX/PROMPTS/cascade-megaboum/REPRISE-aep-carto-fix-batch3.md` :
|
||||
- Bouton "+" → sélecteur 3 cartes (Entraide/Réseaux/Jobs)
|
||||
- Pop-up explication 5 axes Jobs (paragraphe par axe)
|
||||
- Pop-up Carte 1 visibilité (option à clarifier avec Jules)
|
||||
- GraphView Carte 1 (centres = hashtags, couche échelle activable) — gros chantier session dédiée
|
||||
|
||||
---
|
||||
|
||||
## 2026-04-27 — Session V3 : Finition mobile + Blog Liberapay + 3 deploys
|
||||
|
||||
**Commit :** `a02a555` — feat(mobile): accordéon outremer, hamburger nav, logo AEP, fiches cliquables, chatbot fullscreen
|
||||
|
||||
142
app.vue
142
app.vue
@@ -22,12 +22,19 @@
|
||||
|
||||
<!-- ── Onglets desktop (≥1024px) — remplace la barre de recherche ── -->
|
||||
<nav class="hidden lg:flex flex-1 justify-center items-end gap-0 mx-6" aria-label="Navigation projets">
|
||||
<NuxtLink
|
||||
to="/outils"
|
||||
class="nav-tab"
|
||||
:class="{ 'nav-tab--active': route.path === '/outils' }"
|
||||
>
|
||||
Outils
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
to="/"
|
||||
class="nav-tab"
|
||||
:class="{ 'nav-tab--active': route.path === '/' }"
|
||||
>
|
||||
Écosystème Entraide Architecture
|
||||
Écosystème Entraide
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
to="/agences"
|
||||
@@ -51,12 +58,11 @@
|
||||
Codev
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
to="/rag"
|
||||
to="/media"
|
||||
class="nav-tab"
|
||||
:class="{ 'nav-tab--active': route.path === '/rag' }"
|
||||
:class="{ 'nav-tab--active': route.path.startsWith('/media') }"
|
||||
>
|
||||
RAG
|
||||
<span class="nav-tab-badge">en construction</span>
|
||||
recherche-média
|
||||
</NuxtLink>
|
||||
</nav>
|
||||
|
||||
@@ -108,52 +114,14 @@
|
||||
>
|
||||
Signaler
|
||||
</NuxtLink>
|
||||
<!-- Proposer — popover 3 choix -->
|
||||
<div class="hidden sm:block relative" ref="proposerAnchor" data-proposer-popover>
|
||||
<button
|
||||
@click="proposerOpen = !proposerOpen"
|
||||
class="px-3 py-1.5 rounded-lg text-sm font-semibold transition-all hover:opacity-80 inline-flex items-center gap-1"
|
||||
style="background: var(--nav-accent); color: var(--nav-text);"
|
||||
aria-label="Proposer une contribution"
|
||||
>
|
||||
+ Proposer
|
||||
</button>
|
||||
<div
|
||||
v-if="proposerOpen"
|
||||
class="absolute right-0 top-full mt-1 rounded-lg shadow-lg min-w-[240px] py-1"
|
||||
style="background: var(--nav-surface); border: 1px solid var(--nav-bg-alt); z-index: 9999;"
|
||||
>
|
||||
<NuxtLink
|
||||
to="/contribuer"
|
||||
class="flex items-center justify-between px-4 py-2.5 text-sm font-medium transition-opacity hover:opacity-70"
|
||||
style="color: var(--nav-text);"
|
||||
@click="proposerOpen = false"
|
||||
>
|
||||
<span>Fiche Entraide <span style="color: var(--nav-text-muted); font-weight: 400; font-size: 0.7rem; display: block;">Carte 1 — Écosystème archi</span></span>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0; color: var(--nav-text-muted);"><polyline points="9 18 15 12 9 6"/></svg>
|
||||
</NuxtLink>
|
||||
<div style="height: 1px; background: var(--nav-bg-alt); margin: 2px 0;"></div>
|
||||
<NuxtLink
|
||||
to="/contribuer-reseau"
|
||||
class="flex items-center justify-between px-4 py-2.5 text-sm font-medium transition-opacity hover:opacity-70"
|
||||
style="color: var(--nav-text);"
|
||||
@click="proposerOpen = false"
|
||||
>
|
||||
<span>Réseau / collectif <span style="color: var(--nav-text-muted); font-weight: 400; font-size: 0.7rem; display: block;">Carte 2 — Réseaux AEP</span></span>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0; color: var(--nav-text-muted);"><polyline points="9 18 15 12 9 6"/></svg>
|
||||
</NuxtLink>
|
||||
<div style="height: 1px; background: var(--nav-bg-alt); margin: 2px 0;"></div>
|
||||
<NuxtLink
|
||||
to="/contribuer-job"
|
||||
class="flex items-center justify-between px-4 py-2.5 text-sm font-medium transition-opacity hover:opacity-70"
|
||||
style="color: var(--nav-text);"
|
||||
@click="proposerOpen = false"
|
||||
>
|
||||
<span>Plateforme jobs <span style="color: var(--nav-text-muted); font-weight: 400; font-size: 0.7rem; display: block;">Carte 3 — Jobs archi</span></span>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0; color: var(--nav-text-muted);"><polyline points="9 18 15 12 9 6"/></svg>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Proposer une ressource -->
|
||||
<NuxtLink
|
||||
to="/contribuer"
|
||||
class="px-3 py-1.5 rounded-lg text-sm font-semibold transition-all hover:opacity-80 hidden sm:inline-flex items-center gap-1"
|
||||
style="background: var(--nav-accent); color: var(--nav-text);"
|
||||
>
|
||||
+ Proposer
|
||||
</NuxtLink>
|
||||
|
||||
<!-- Toggle dark mode -->
|
||||
<button
|
||||
@@ -175,40 +143,18 @@
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Mobile : contribuer icône → popover -->
|
||||
<div class="sm:hidden relative" data-proposer-popover>
|
||||
<button
|
||||
@click="proposerOpen = !proposerOpen"
|
||||
class="p-2 rounded-lg"
|
||||
style="background: var(--nav-accent); color: var(--nav-text);"
|
||||
title="Contribuer"
|
||||
aria-label="Contribuer"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div
|
||||
v-if="proposerOpen"
|
||||
class="absolute right-0 top-full mt-1 rounded-lg shadow-lg min-w-[220px] py-1"
|
||||
style="background: var(--nav-surface); border: 1px solid var(--nav-bg-alt); z-index: 9999;"
|
||||
>
|
||||
<NuxtLink to="/contribuer" class="flex items-center justify-between px-4 py-2.5 text-sm font-medium transition-opacity hover:opacity-70" style="color: var(--nav-text);" @click="proposerOpen = false">
|
||||
<span>Fiche Entraide</span>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0; color: var(--nav-text-muted);"><polyline points="9 18 15 12 9 6"/></svg>
|
||||
</NuxtLink>
|
||||
<div style="height: 1px; background: var(--nav-bg-alt); margin: 2px 0;"></div>
|
||||
<NuxtLink to="/contribuer-reseau" class="flex items-center justify-between px-4 py-2.5 text-sm font-medium transition-opacity hover:opacity-70" style="color: var(--nav-text);" @click="proposerOpen = false">
|
||||
<span>Réseau / collectif</span>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0; color: var(--nav-text-muted);"><polyline points="9 18 15 12 9 6"/></svg>
|
||||
</NuxtLink>
|
||||
<div style="height: 1px; background: var(--nav-bg-alt); margin: 2px 0;"></div>
|
||||
<NuxtLink to="/contribuer-job" class="flex items-center justify-between px-4 py-2.5 text-sm font-medium transition-opacity hover:opacity-70" style="color: var(--nav-text);" @click="proposerOpen = false">
|
||||
<span>Plateforme jobs</span>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0; color: var(--nav-text-muted);"><polyline points="9 18 15 12 9 6"/></svg>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Mobile : contribuer icône -->
|
||||
<NuxtLink
|
||||
to="/contribuer"
|
||||
class="sm:hidden p-2 rounded-lg"
|
||||
style="background: var(--nav-accent); color: var(--nav-text);"
|
||||
title="Contribuer une fiche"
|
||||
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>
|
||||
</NuxtLink>
|
||||
|
||||
<!-- Hamburger mobile (lg:hidden) — toujours en dernier à droite -->
|
||||
<div class="lg:hidden relative">
|
||||
@@ -232,6 +178,7 @@
|
||||
style="background: var(--nav-surface); border: 1px solid var(--nav-bg-alt); z-index: 9999;"
|
||||
@click="hamburgerOpen = false"
|
||||
>
|
||||
<NuxtLink to="/outils" class="block px-4 py-2.5 text-sm font-medium transition-opacity hover:opacity-70" :style="route.path === '/outils' ? 'color: var(--nav-primary-solid); font-weight: 700;' : 'color: var(--nav-text);'">Outils</NuxtLink>
|
||||
<NuxtLink to="/" class="block px-4 py-2.5 text-sm font-medium transition-opacity hover:opacity-70" :style="route.path === '/' ? 'color: var(--nav-primary-solid); font-weight: 700;' : 'color: var(--nav-text);'">Écosystème Entraide Architecture</NuxtLink>
|
||||
<NuxtLink to="/agences" class="block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" :style="route.path === '/agences' ? 'color: var(--nav-primary-solid); font-weight: 700;' : 'color: var(--nav-text);'">Réseaux AEP</NuxtLink>
|
||||
<NuxtLink to="/trouver-du-taf" class="block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" :style="route.path === '/trouver-du-taf' ? 'color: var(--nav-primary-solid); font-weight: 700;' : 'color: var(--nav-text);'">Jobs</NuxtLink>
|
||||
@@ -265,31 +212,6 @@ const route = useRoute()
|
||||
const hamburgerOpen = ref(false)
|
||||
watch(() => route.path, () => { hamburgerOpen.value = false })
|
||||
|
||||
// ── Popover "+ Proposer" ─────────────────────────────────────────────────
|
||||
const proposerOpen = ref(false)
|
||||
const proposerAnchor = ref<HTMLElement | null>(null)
|
||||
|
||||
function onClickOutsideProposer(e: MouseEvent) {
|
||||
// Ferme si le clic est hors de tout élément portant data-proposer-popover
|
||||
const target = e.target as HTMLElement
|
||||
if (!target.closest('[data-proposer-popover]')) {
|
||||
proposerOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(proposerOpen, (open) => {
|
||||
if (open) {
|
||||
// Délai court pour ne pas attraper le clic d'ouverture lui-même
|
||||
setTimeout(() => document.addEventListener('click', onClickOutsideProposer, true), 10)
|
||||
} else {
|
||||
document.removeEventListener('click', onClickOutsideProposer, true)
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', onClickOutsideProposer, true)
|
||||
})
|
||||
|
||||
// ── Dark mode ─────────────────────────────────────────────────────────────
|
||||
const isDark = ref(false)
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div style="width: 100%; height: 100%; position: relative; background: var(--nav-bg);">
|
||||
<div style="width: 100%; height: 100%; position: relative; background: #f5f3f0;">
|
||||
<svg ref="svgRef" style="width: 100%; height: 100%;"></svg>
|
||||
<div ref="tooltipRef" style="
|
||||
position: absolute; pointer-events: none;
|
||||
@@ -14,21 +14,45 @@
|
||||
<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 AuteurData { id: string; nom: string; dates: string; ecoles: string[]; ecole_principale: string; ingere: boolean; livres_rag: LivreRag[]; theses_cles: string[]; bio_courte: string; bio_courte_provisoire?: string }
|
||||
interface PenseesData { ecoles: EcoleData[]; auteurs: AuteurData[] }
|
||||
|
||||
// Liens d'influence inter-ecoles (Phase 7 - matrice de filiation)
|
||||
const LINKS_INFLUENCE = [
|
||||
// filiations directes
|
||||
{ source: 'eco-anarchisme', target: 'technocritique', auteurs_passerelle: ['Bookchin', 'Illich'], type: 'filiation' },
|
||||
{ source: 'eco-anarchisme', target: 'decroissance', auteurs_passerelle: ['Latouche', 'Kropotkine'], type: 'filiation' },
|
||||
{ source: 'ecosocialisme', target: 'decroissance', auteurs_passerelle: ['Saito', 'Gorz'], type: 'filiation' },
|
||||
{ source: 'ecosocialisme', target: 'ecologies-decoloniales', auteurs_passerelle: ['Klein', 'Ferdinand'], type: 'filiation' },
|
||||
{ source: 'ecofeminismes', target: 'ecologies-decoloniales', auteurs_passerelle: ['Shiva', 'Ouassak'], type: 'filiation' },
|
||||
{ source: 'ecofeminismes', target: 'pensees-vivant', auteurs_passerelle: ['Haraway', 'Despret'], type: 'filiation' },
|
||||
{ source: 'technocritique', target: 'decroissance', auteurs_passerelle: ['Ellul', 'Latouche'], type: 'filiation' },
|
||||
{ source: 'decroissance', target: 'pensees-vivant', auteurs_passerelle: ['Servigne', 'Despret'], type: 'filiation' },
|
||||
{ source: 'pensees-vivant', target: 'ethiques-environnementales', auteurs_passerelle: ['Naess', 'Latour'], type: 'filiation' },
|
||||
{ source: 'ecosocialisme', target: 'eco-anarchisme', auteurs_passerelle: ['Gorz', 'Graeber'], type: 'filiation' },
|
||||
// liens de critique
|
||||
{ source: 'ecosocialisme', target: 'capitalisme-vert', auteurs_passerelle: ['Klein', 'Malm'], type: 'critique' },
|
||||
{ source: 'decroissance', target: 'capitalisme-vert', auteurs_passerelle: ['Latouche', 'Meadows'], type: 'critique' },
|
||||
{ source: 'eco-anarchisme', target: 'capitalisme-vert', auteurs_passerelle: ['Bookchin'], type: 'critique' },
|
||||
{ source: 'ethiques-environnementales', target: 'ecofascismes', auteurs_passerelle: ['Naess'], type: 'critique' },
|
||||
{ source: 'capitalisme-vert', target: 'ecofascismes', auteurs_passerelle: [], type: 'critique' },
|
||||
]
|
||||
|
||||
const props = defineProps<{ data: PenseesData | null; active?: boolean }>()
|
||||
const emit = defineEmits<{ 'select-auteur': [id: string] }>()
|
||||
const emit = defineEmits<{ 'select-auteur': [id: string]; 'select-ecole': [id: string] }>()
|
||||
|
||||
const svgRef = ref<SVGElement | null>(null)
|
||||
const tooltipRef = ref<HTMLElement | null>(null)
|
||||
let simulation: any = null
|
||||
let d3NodeSel: any = null
|
||||
let d3LinkSel: any = null
|
||||
let d3InfluenceSel: any = null
|
||||
let d3NodeSel: any = null
|
||||
let d3EdgeLabelSel: any = null
|
||||
|
||||
async function initGraph() {
|
||||
if (!svgRef.value || !props.data) return
|
||||
const d3 = await import('d3')
|
||||
|
||||
const svgEl = svgRef.value
|
||||
const W = svgEl.clientWidth || 900
|
||||
const H = svgEl.clientHeight || 600
|
||||
@@ -41,72 +65,231 @@ async function initGraph() {
|
||||
|
||||
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,
|
||||
}))
|
||||
// Positions fixes des ecoles (base pour forces D3)
|
||||
const ecolePositions = new Map<string, { tx: number; ty: number }>()
|
||||
props.data.ecoles.forEach(e => {
|
||||
ecolePositions.set(e.id, { tx: W * e.x_hint, ty: H * e.y_hint })
|
||||
})
|
||||
|
||||
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,
|
||||
}))
|
||||
// ---- LIENS D'INFLUENCE INTER-ECOLES (couche 3) ----
|
||||
const gInfluence = g.append('g').attr('class', 'links-influence')
|
||||
|
||||
const allNodes = [...ecoleNodes, ...auteurNodes]
|
||||
LINKS_INFLUENCE.forEach(link => {
|
||||
const src = ecolePositions.get(link.source)
|
||||
const tgt = ecolePositions.get(link.target)
|
||||
if (!src || !tgt) return
|
||||
|
||||
const isCritique = link.type === 'critique'
|
||||
const lineEl = gInfluence.append('line')
|
||||
.attr('class', 'influence-link')
|
||||
.attr('x1', src.tx).attr('y1', src.ty)
|
||||
.attr('x2', tgt.tx).attr('y2', tgt.ty)
|
||||
.attr('stroke', isCritique ? '#d99' : '#9aa')
|
||||
.attr('stroke-width', 1)
|
||||
.attr('stroke-dasharray', isCritique ? '4,3' : '6,4')
|
||||
.attr('stroke-opacity', isCritique ? 0.2 : 0.22)
|
||||
|
||||
if (link.auteurs_passerelle && link.auteurs_passerelle.length > 0) {
|
||||
lineEl
|
||||
.on('mouseenter', (e: any) => {
|
||||
if (!tooltipRef.value) return
|
||||
tooltipRef.value.innerHTML = `<strong>Influence</strong><br><span style="opacity:0.8;font-size:0.72rem;">Passerelles : ${link.auteurs_passerelle.join(', ')}</span>`
|
||||
tooltipRef.value.style.opacity = '1'
|
||||
})
|
||||
.on('mousemove', (e: any) => {
|
||||
if (!tooltipRef.value || !svgEl) return
|
||||
const rect = (svgEl as HTMLElement).getBoundingClientRect()
|
||||
tooltipRef.value.style.left = (e.clientX - rect.left + 14) + 'px'
|
||||
tooltipRef.value.style.top = (e.clientY - rect.top - 10) + 'px'
|
||||
})
|
||||
.on('mouseleave', () => { if (tooltipRef.value) tooltipRef.value.style.opacity = '0' })
|
||||
}
|
||||
})
|
||||
|
||||
// ---- SIMULATION D3 (auteurs) ----
|
||||
const auteurNodes: any[] = props.data.auteurs.map(a => {
|
||||
const ecole = ecoleMap.get(a.ecole_principale)
|
||||
const jitter = () => (Math.random() - 0.5) * 80
|
||||
return {
|
||||
id: a.id, type: 'auteur', nom: a.nom, dates: a.dates,
|
||||
bio_courte: a.bio_courte,
|
||||
bio_provisoire: a.bio_courte_provisoire ?? '',
|
||||
ingere: a.ingere,
|
||||
ecole_principale: a.ecole_principale,
|
||||
color: ecole?.color ?? '#888', r: 11,
|
||||
x: W * (ecole?.x_hint ?? 0.5) + jitter(),
|
||||
y: H * (ecole?.y_hint ?? 0.5) + jitter(),
|
||||
}
|
||||
})
|
||||
|
||||
// Liens appartenance auteur -> ecole (vers centroid fixe)
|
||||
const links: any[] = []
|
||||
props.data.auteurs.forEach(a => {
|
||||
links.push({ source: a.id, target: `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 }))
|
||||
links.push({ source: a.id, target: a.ecole_principale, strength: 0.65, isSubcourant: false })
|
||||
a.ecoles.filter(e => e !== a.ecole_principale).forEach(e => {
|
||||
links.push({ source: a.id, target: e, strength: 0.25, isSubcourant: true })
|
||||
})
|
||||
})
|
||||
|
||||
// Nodes fictifs fixes pour les ecoles (cibles des liens appartenance)
|
||||
const ecoleFixedNodes: any[] = props.data.ecoles.map(e => ({
|
||||
id: e.id, type: 'ecole-fixed', ecoleId: e.id,
|
||||
x: W * e.x_hint, y: H * e.y_hint,
|
||||
fx: W * e.x_hint, fy: H * e.y_hint,
|
||||
}))
|
||||
|
||||
// Rayon proportionnel au nombre d'auteurs de l'ecole
|
||||
const ecoleAuteurCounts = new Map<string, number>()
|
||||
props.data.ecoles.forEach(e => ecoleAuteurCounts.set(e.id, 0))
|
||||
props.data.auteurs.forEach(a => ecoleAuteurCounts.set(a.ecole_principale, (ecoleAuteurCounts.get(a.ecole_principale) ?? 0) + 1))
|
||||
const ecoleRadius = (count: number) => Math.max(16, Math.min(36, 13 + count * 1.5))
|
||||
|
||||
const allNodes = [...ecoleFixedNodes, ...auteurNodes]
|
||||
|
||||
if (simulation) simulation.stop()
|
||||
simulation = d3.forceSimulation(allNodes)
|
||||
.force('link', d3.forceLink(links).id((d: any) => d.id).distance(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))
|
||||
.force('link', d3.forceLink(links).id((d: any) => d.id).distance(120).strength((d: any) => d.strength ?? 0.5))
|
||||
.force('charge', d3.forceManyBody().strength(-70))
|
||||
.force('center', d3.forceCenter(W / 2, H / 2).strength(0.02))
|
||||
.force('collision', d3.forceCollide().radius((d: any) => d.type === 'ecole-fixed' ? ecoleRadius(ecoleAuteurCounts.get(d.ecoleId) ?? 0) + 4 : 12))
|
||||
.force('forceX', d3.forceX<any>((d: any) => {
|
||||
if (d.type === 'auteur') {
|
||||
const pos = ecolePositions.get(d.ecole_principale)
|
||||
return pos ? pos.tx : W / 2
|
||||
}
|
||||
return W / 2
|
||||
}).strength(0.15))
|
||||
.force('forceY', d3.forceY<any>((d: any) => {
|
||||
if (d.type === 'auteur') {
|
||||
const pos = ecolePositions.get(d.ecole_principale)
|
||||
return pos ? pos.ty : H / 2
|
||||
}
|
||||
return H / 2
|
||||
}).strength(0.15))
|
||||
|
||||
d3LinkSel = g.append('g').selectAll('line').data(links).join('line')
|
||||
.attr('stroke', 'rgba(150,150,150,0.3)').attr('stroke-width', 1.2)
|
||||
// ---- NOEUDS ECOLES visibles (couche 3.5) ----
|
||||
const gEcoles = g.append('g').attr('class', 'ecoles-nodes')
|
||||
ecoleFixedNodes.forEach(eNode => {
|
||||
const ecole = ecoleMap.get(eNode.ecoleId)
|
||||
if (!ecole) return
|
||||
const count = ecoleAuteurCounts.get(eNode.ecoleId) ?? 0
|
||||
const r = ecoleRadius(count)
|
||||
gEcoles.append('circle')
|
||||
.attr('cx', eNode.fx).attr('cy', eNode.fy).attr('r', r)
|
||||
.attr('fill', ecole.color).attr('fill-opacity', 0.82).attr('stroke', ecole.color).attr('stroke-width', 2)
|
||||
.attr('class', 'ecole-node').style('cursor', 'pointer')
|
||||
.on('mouseenter', (e: any) => {
|
||||
if (!tooltipRef.value) return
|
||||
tooltipRef.value.innerHTML = `<strong>${ecole.label}</strong> <span style="opacity:0.6;font-size:0.7rem;">${count} auteur${count > 1 ? 's' : ''}</span><br><span style="opacity:0.75;font-size:0.72rem;">${ecole.description}</span>`
|
||||
tooltipRef.value.style.opacity = '1'
|
||||
})
|
||||
.on('mousemove', (e: any) => {
|
||||
if (!tooltipRef.value || !svgEl) return
|
||||
const rect = (svgEl as HTMLElement).getBoundingClientRect()
|
||||
tooltipRef.value.style.left = (e.clientX - rect.left + 14) + 'px'
|
||||
tooltipRef.value.style.top = (e.clientY - rect.top - 10) + 'px'
|
||||
})
|
||||
.on('mouseleave', () => { if (tooltipRef.value) tooltipRef.value.style.opacity = '0' })
|
||||
.on('click', (e: any) => { e.stopPropagation(); emit('select-ecole', eNode.ecoleId) })
|
||||
|
||||
d3NodeSel = g.append('g').selectAll('g').data(allNodes).join('g')
|
||||
.style('cursor', (d: any) => d.type === 'auteur' ? 'pointer' : 'default')
|
||||
// ---- TITRES ECOLES visibles en permanence ----
|
||||
const labelText = ecole.label
|
||||
const words = labelText.split(' ')
|
||||
const fontSize = Math.max(12, r * 0.45)
|
||||
if (words.length > 2 || labelText.length > 12) {
|
||||
const mid = Math.ceil(words.length / 2)
|
||||
const line1 = words.slice(0, mid).join(' ')
|
||||
const line2 = words.slice(mid).join(' ')
|
||||
const textEl = gEcoles.append('text')
|
||||
.attr('x', eNode.fx)
|
||||
.attr('y', eNode.fy)
|
||||
.attr('text-anchor', 'middle')
|
||||
.attr('dominant-baseline', 'middle')
|
||||
.style('pointer-events', 'none')
|
||||
.style('font-weight', '700')
|
||||
.style('font-size', `${fontSize}px`)
|
||||
.style('fill', '#ffffff')
|
||||
.style('text-shadow', '0 1px 3px rgba(0,0,0,0.5)')
|
||||
.style('user-select', 'none')
|
||||
textEl.append('tspan')
|
||||
.attr('x', eNode.fx)
|
||||
.attr('dy', `-${fontSize * 0.6}px`)
|
||||
.text(line1)
|
||||
textEl.append('tspan')
|
||||
.attr('x', eNode.fx)
|
||||
.attr('dy', `${fontSize * 1.2}px`)
|
||||
.text(line2)
|
||||
} else {
|
||||
gEcoles.append('text')
|
||||
.attr('x', eNode.fx)
|
||||
.attr('y', eNode.fy)
|
||||
.attr('text-anchor', 'middle')
|
||||
.attr('dominant-baseline', 'middle')
|
||||
.style('pointer-events', 'none')
|
||||
.style('font-weight', '700')
|
||||
.style('font-size', `${fontSize}px`)
|
||||
.style('fill', '#ffffff')
|
||||
.style('user-select', 'none')
|
||||
.text(labelText)
|
||||
}
|
||||
})
|
||||
|
||||
// ---- LIENS APPARTENANCE (couche 4) ----
|
||||
const gLinks = g.append('g').attr('class', 'links-appartenance')
|
||||
d3LinkSel = gLinks.selectAll('line').data(links).join('line')
|
||||
.attr('stroke', 'rgba(150,150,150,0.28)').attr('stroke-width', 1.2)
|
||||
|
||||
// ---- EDGE LABELS - sous-courants (couche 4b) ----
|
||||
const subcourantLinks = links.filter((l: any) => l.isSubcourant)
|
||||
d3EdgeLabelSel = gLinks.selectAll('text.pensees-edge-label')
|
||||
.data(subcourantLinks)
|
||||
.join('text')
|
||||
.attr('class', 'pensees-edge-label')
|
||||
|
||||
// ---- NODES AUTEURS (couche 5) ----
|
||||
const gAuteurs = g.append('g').attr('class', 'auteurs')
|
||||
d3NodeSel = gAuteurs.selectAll('g').data(auteurNodes).join('g')
|
||||
.style('cursor', (d: any) => d.ingere ? 'pointer' : 'default')
|
||||
.call(d3.drag<any, any>()
|
||||
.on('start', (e: any, d: any) => { if (!e.active) simulation.alphaTarget(0.3).restart(); d.fx = d.x; d.fy = d.y })
|
||||
.on('drag', (e: any, d: any) => { d.fx = e.x; d.fy = e.y })
|
||||
.on('end', (e: any, d: any) => { if (!e.active) simulation.alphaTarget(0); 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(' '))
|
||||
}
|
||||
.on('end', (e: any, d: any) => { if (!e.active) simulation.alphaTarget(0); d.fx = null; d.fy = null }))
|
||||
.on('click', (e: any, d: any) => {
|
||||
if (!d.ingere) return
|
||||
e.stopPropagation()
|
||||
emit('select-auteur', d.id)
|
||||
})
|
||||
|
||||
d3NodeSel.filter((d: any) => d.type === 'auteur').append('text')
|
||||
// Phase 8.D : grisage conditionnel auteurs non-ingeres
|
||||
d3NodeSel.append('circle')
|
||||
.attr('r', (d: any) => d.r)
|
||||
.attr('fill', (d: any) => d.ingere ? (d.color + 'cc') : '#bbbbbb')
|
||||
.attr('stroke', (d: any) => d.ingere ? d.color : '#999999')
|
||||
.attr('stroke-width', 1.5)
|
||||
.attr('opacity', (d: any) => d.ingere ? 1 : 0.35)
|
||||
|
||||
// ---- LABELS AUTEURS (couche 6 - drop-shadow blanc) ----
|
||||
d3NodeSel.append('text')
|
||||
.attr('class', 'pensees-auteur-label')
|
||||
.text((d: any) => d.nom.split(' ').pop() ?? d.nom)
|
||||
.attr('text-anchor', 'middle').attr('dy', (d: any) => -(d.r + 4)).attr('font-size', '9px').attr('font-weight', '500')
|
||||
.attr('text-anchor', 'middle')
|
||||
.attr('dy', (d: any) => -(d.r + 4))
|
||||
.style('pointer-events', 'none')
|
||||
.style('opacity', (d: any) => d.ingere ? 1 : 0.3)
|
||||
.style('fill', (d: any) => d.ingere ? '#1a1a1a' : '#777777')
|
||||
|
||||
d3NodeSel.filter((d: any) => d.type === 'auteur')
|
||||
d3NodeSel
|
||||
.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>`
|
||||
let tooltipHtml = ''
|
||||
if (d.ingere) {
|
||||
const rawBio = d.bio_courte || ''
|
||||
const bio = rawBio.length > 90 ? rawBio.slice(0, 87) + '...' : rawBio
|
||||
tooltipHtml = `<strong>${d.nom}</strong> <span style="opacity:0.6;font-size:0.7rem;">${d.dates}</span><br><span style="opacity:0.75;font-size:0.72rem;">${bio || 'Dans le RAG ATIS.'}</span>`
|
||||
} else {
|
||||
tooltipHtml = `<strong>${d.nom}</strong> <span style="opacity:0.6;font-size:0.7rem;">${d.dates}</span><br><span style="opacity:0.65;font-size:0.72rem;font-style:italic;">Présent dans Bonpote, pas encore ingéré dans le RAG ATIS.</span>`
|
||||
}
|
||||
tooltipRef.value.innerHTML = tooltipHtml
|
||||
tooltipRef.value.style.opacity = '1'
|
||||
})
|
||||
.on('mousemove', (e: any) => {
|
||||
@@ -118,18 +301,38 @@ async function initGraph() {
|
||||
.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)
|
||||
d3LinkSel
|
||||
.attr('x1', (d: any) => d.source.x).attr('y1', (d: any) => d.source.y)
|
||||
.attr('x2', (d: any) => d.target.x).attr('y2', (d: any) => d.target.y)
|
||||
|
||||
d3EdgeLabelSel
|
||||
.attr('x', (d: any) => (d.source.x + d.target.x) / 2)
|
||||
.attr('y', (d: any) => (d.source.y + d.target.y) / 2)
|
||||
.text((d: any) => {
|
||||
const targetId = typeof d.target === 'object' ? d.target.id : d.target
|
||||
return targetId
|
||||
})
|
||||
|
||||
d3NodeSel.attr('transform', (d: any) => `translate(${d.x},${d.y})`)
|
||||
})
|
||||
}
|
||||
|
||||
watch(() => props.active, (val) => { if (val && import.meta.client && props.data) requestAnimationFrame(() => requestAnimationFrame(() => initGraph())) })
|
||||
watch(() => props.data, (val) => { if (val && props.active && import.meta.client) requestAnimationFrame(() => requestAnimationFrame(() => initGraph())) })
|
||||
onMounted(async () => { if (import.meta.client && props.data && props.active) { await nextTick(); initGraph() } })
|
||||
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()
|
||||
@@ -141,5 +344,29 @@ 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; }
|
||||
.pensees-auteur-label {
|
||||
fill: #1a1a1a;
|
||||
font-weight: 600;
|
||||
font-size: 10px;
|
||||
filter: drop-shadow(0 0 2.5px rgba(255,255,255,0.95));
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.pensees-edge-label {
|
||||
fill: #555;
|
||||
font-size: 8.5px;
|
||||
font-style: italic;
|
||||
opacity: 0.7;
|
||||
text-anchor: middle;
|
||||
dominant-baseline: middle;
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.ecole-node {
|
||||
transition: opacity 0.15s, r 0.15s;
|
||||
}
|
||||
.ecole-node:hover {
|
||||
opacity: 0.75;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,296 +0,0 @@
|
||||
<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,10 +52,9 @@
|
||||
<div class="chatbot-body-inner" ref="messagesContainer">
|
||||
<!-- Onboarding -->
|
||||
<div v-if="messages.length === 0" class="onboarding-bubble">
|
||||
<p>Je connais les structures d'entraide pour architectes référencées sur cette carte — appui juridique, technique, économique, formation, santé mentale, gestion d'agence…</p>
|
||||
<p>Décris ta situation, je te propose les fiches les plus pertinentes.</p>
|
||||
<p class="example">Exemple : "Architecte salarié, litige avec mon employeur, besoin d'un appui juridique droit du travail, Île-de-France."</p>
|
||||
<p style="margin-top: 8px; font-size: 0.72rem; opacity: 0.6;">Propulsé par Mistral FR — serveur européen souverain, zéro rétention.</p>
|
||||
<p>Explore les 120 structures de la carte par la conversation. Je peux t'aider à trouver des collectifs, agences ou réseaux selon ta situation, ta pratique ou tes inspirations du moment.</p>
|
||||
<p class="example">Exemple : "Je cherche des acteurs de la rénovation de maisons individuelles en France, plutôt en milieu rural, avec des approches biosourcées ou low-tech."</p>
|
||||
<p style="margin-top: 8px; font-size: 0.72rem; opacity: 0.6;">Propulsé par Mistral FR - serveur européen souverain, zéro rétention.</p>
|
||||
</div>
|
||||
|
||||
<!-- Messages -->
|
||||
|
||||
@@ -1,16 +1,38 @@
|
||||
<template>
|
||||
<div class="space-y-1">
|
||||
<p class="filter-label">ÉCHELLE</p>
|
||||
<div class="chips-row">
|
||||
<span
|
||||
<div class="space-y-1.5">
|
||||
<p class="text-xs font-bold uppercase tracking-widest" style="color: var(--nav-text-muted);">Échelle</p>
|
||||
<!-- Inline sur 1 ligne — même pattern que FonctionFilter -->
|
||||
<div class="flex flex-wrap gap-x-4 gap-y-1.5">
|
||||
<label
|
||||
v-for="option in ECHELLES"
|
||||
:key="option"
|
||||
class="chip"
|
||||
:style="isSelected(option)
|
||||
? 'background: var(--nav-primary); color: var(--nav-text-on-primary); font-weight: 600;'
|
||||
: 'background: var(--nav-bg-alt); color: var(--nav-text-muted);'"
|
||||
@click="toggle(option)"
|
||||
>{{ option }}</span>
|
||||
class="flex items-center gap-1.5 cursor-pointer select-none transition-opacity"
|
||||
>
|
||||
<!-- Case carrée -->
|
||||
<span
|
||||
class="flex items-center justify-center shrink-0 transition-all"
|
||||
style="width: 18px; height: 18px; border: 1.5px solid; border-radius: 3px;"
|
||||
:style="isSelected(option)
|
||||
? 'background: var(--nav-primary); border-color: var(--nav-primary); color: #ffffff;'
|
||||
: 'background: var(--nav-bg-alt); border-color: rgba(26,34,56,0.25); color: transparent;'"
|
||||
>
|
||||
<svg v-if="isSelected(option)" width="11" height="11" viewBox="0 0 12 12" fill="none">
|
||||
<polyline points="2,6 5,9 10,3" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</span>
|
||||
<!-- Label -->
|
||||
<span
|
||||
class="text-sm leading-tight"
|
||||
:style="isSelected(option) ? 'color: var(--nav-text); font-weight: 600;' : 'color: var(--nav-text);'"
|
||||
>{{ option }}</span>
|
||||
<!-- Input réel (masqué) -->
|
||||
<input
|
||||
type="checkbox"
|
||||
class="sr-only"
|
||||
:checked="isSelected(option)"
|
||||
@change="toggle(option)"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -39,24 +61,3 @@ function toggle(option: string) {
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.filter-label {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--nav-text-muted);
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.chips-row { display: flex; flex-wrap: wrap; gap: 4px; margin-bottom: 4px; }
|
||||
.chip {
|
||||
cursor: pointer;
|
||||
padding: 3px 10px;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.75rem;
|
||||
transition: all 0.15s;
|
||||
user-select: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
<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>
|
||||
@@ -1,33 +1,35 @@
|
||||
<template>
|
||||
<div class="space-y-1">
|
||||
<!-- Label + toggle collapse -->
|
||||
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 4px;">
|
||||
<p class="filter-label" style="margin-bottom: 0;">
|
||||
FONCTION
|
||||
<span v-if="modelValue.length" style="font-weight: 400; text-transform: none; letter-spacing: 0; font-size: 0.65rem; margin-left: 4px;">({{ modelValue.length }} active{{ modelValue.length > 1 ? 's' : '' }})</span>
|
||||
</p>
|
||||
<div class="space-y-1.5">
|
||||
<p class="text-xs font-bold uppercase tracking-widest" style="color: var(--nav-text-muted);">Fonction</p>
|
||||
<div class="space-y-1">
|
||||
<button
|
||||
@click="toggleCollapse"
|
||||
style="font-size: 0.7rem; color: var(--nav-text-muted); background: none; border: none; cursor: pointer; text-decoration: underline; padding: 0; white-space: nowrap;"
|
||||
>{{ isOpen ? 'Replier' : 'Fonctions (' + FONCTIONS.length + ')' }}</button>
|
||||
</div>
|
||||
|
||||
<!-- Chips (visible si ouvert ou si des fonctions sont actives) -->
|
||||
<div v-if="isOpen" class="chips-row">
|
||||
<span
|
||||
v-for="fn in FONCTIONS"
|
||||
:key="fn"
|
||||
class="chip"
|
||||
:style="modelValue.includes(fn)
|
||||
? 'background: var(--nav-primary); color: var(--nav-text-on-primary); font-weight: 600;'
|
||||
: 'background: var(--nav-bg-alt); color: var(--nav-text-muted);'"
|
||||
@click="toggle(fn)"
|
||||
>{{ fn }}</span>
|
||||
:aria-pressed="modelValue.includes(fn)"
|
||||
class="flex items-center gap-2.5 w-full rounded px-1 py-0.5 transition-all text-left hover:opacity-80"
|
||||
:style="modelValue.includes(fn) ? 'background: rgba(26,34,56,0.06);' : ''"
|
||||
>
|
||||
<!-- Case : affiche le rang de priorité si actif, sinon le nombre d'orgs -->
|
||||
<span
|
||||
class="flex items-center justify-center shrink-0 text-xs font-bold transition-all"
|
||||
style="width: 24px; height: 24px; border: 1.5px solid; border-radius: 4px;"
|
||||
:style="modelValue.includes(fn)
|
||||
? 'background: var(--nav-primary); border-color: var(--nav-primary); color: var(--nav-text-on-primary);'
|
||||
: 'background: var(--nav-bg-alt); border-color: var(--nav-bg-alt); color: var(--nav-text-muted);'"
|
||||
>
|
||||
{{ modelValue.includes(fn) ? (modelValue.indexOf(fn) + 1) : (counts[fn] ?? 0) }}
|
||||
</span>
|
||||
<!-- Label -->
|
||||
<span
|
||||
class="text-sm leading-tight"
|
||||
:style="modelValue.includes(fn) ? 'color: var(--nav-text); font-weight: 600;' : 'color: var(--nav-text);'"
|
||||
>{{ fn }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Effacer (visible même replié si filtres actifs) -->
|
||||
<p v-if="modelValue.length" class="text-xs pt-0.5" style="color: var(--nav-text-muted);">
|
||||
<button @click="emit('update:modelValue', [])" class="underline hover:opacity-70">Effacer</button>
|
||||
{{ modelValue.length }} actif{{ modelValue.length > 1 ? 's' : '' }}
|
||||
<button @click="emit('update:modelValue', [])" class="ml-2 underline hover:opacity-70">Effacer</button>
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -55,25 +57,6 @@ const emit = defineEmits<{
|
||||
'update:modelValue': [value: string[]]
|
||||
}>()
|
||||
|
||||
// Replié par défaut, ouvre automatiquement quand des filtres sont actifs
|
||||
const manuallyOpen = ref(false)
|
||||
|
||||
const isOpen = computed(() => {
|
||||
return manuallyOpen.value || props.modelValue.length > 0
|
||||
})
|
||||
|
||||
function toggleCollapse() {
|
||||
// Si des filtres actifs forcent l'ouverture, on doit gérer le cas « forcer fermer »
|
||||
if (isOpen.value) {
|
||||
manuallyOpen.value = false
|
||||
// Si des fonctions sont actives, le computed va les réouvrir — on les efface
|
||||
// Non : on laisse le choix à l'utilisateur. On toggle juste manuallyOpen.
|
||||
// Quand replié avec filtres actifs, l'indicateur "(N actives)" reste visible.
|
||||
} else {
|
||||
manuallyOpen.value = true
|
||||
}
|
||||
}
|
||||
|
||||
function toggle(fn: string) {
|
||||
if (props.modelValue.includes(fn)) {
|
||||
emit('update:modelValue', props.modelValue.filter(f => f !== fn))
|
||||
@@ -82,23 +65,3 @@ function toggle(fn: string) {
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.filter-label {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--nav-text-muted);
|
||||
display: block;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.chips-row { display: flex; flex-wrap: wrap; gap: 4px; margin-bottom: 4px; }
|
||||
.chip {
|
||||
cursor: pointer;
|
||||
padding: 3px 10px;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.75rem;
|
||||
transition: all 0.15s;
|
||||
user-select: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -877,7 +877,6 @@ onUnmounted(() => {
|
||||
/* Labels des structures dans le graphe (D3 injecte les <text>, donc style global) */
|
||||
.graph-view .graph-struct-label {
|
||||
fill: var(--nav-text);
|
||||
opacity: 0.7;
|
||||
paint-order: stroke;
|
||||
stroke: var(--nav-bg);
|
||||
stroke-width: 3px;
|
||||
|
||||
19
components/MediaTabBackend.vue
Normal file
19
components/MediaTabBackend.vue
Normal file
@@ -0,0 +1,19 @@
|
||||
<template>
|
||||
<div class="media-tab-backend" style="padding: 2rem; overflow-y: auto;">
|
||||
<div style="max-width: 640px;">
|
||||
<h2 style="font-weight: 700; font-size: 1.1rem; margin-bottom: 0.75rem; color: var(--nav-text);">LightRAG backend</h2>
|
||||
<p style="font-size: 0.9rem; line-height: 1.6; color: var(--nav-text); margin-bottom: 0.5rem;">
|
||||
Voici l'interface brute du <strong>LightRAG</strong> qui alimente la carte des pensées écologiques.
|
||||
C'est la "cuisine" du RAG : ingestion de documents, extraction d'entités, relations, requêtes.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<iframe
|
||||
src="https://lightrag.trans-former.fr/"
|
||||
style="width: 100%; height: 70vh; border: 1px solid var(--nav-bg-alt, #ddd); border-radius: 8px; margin-top: 1.5rem;"
|
||||
title="LightRAG backend AEP — lecture seule"
|
||||
sandbox="allow-same-origin allow-scripts"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
67
components/MediaTabProjets.vue
Normal file
67
components/MediaTabProjets.vue
Normal file
@@ -0,0 +1,67 @@
|
||||
<template>
|
||||
<div class="media-tab-projets" style="padding: 1.5rem; overflow-y: auto;">
|
||||
<div style="max-width: 70ch; margin-bottom: 1.5rem;">
|
||||
<h2 style="font-weight: 700; font-size: 1.1rem; margin-bottom: 0.5rem; color: var(--nav-text);">PFE engagés</h2>
|
||||
<p style="font-size: 0.9rem; line-height: 1.6; color: var(--nav-text);">
|
||||
Mutualiser le savoir. Voici les PFE engagés publiés en ligne dont nous avons connaissance.
|
||||
Partage-nous le lien de ton travail si tu veux participer à cette initiative.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="projets-grid">
|
||||
<article v-for="p in projets" :key="p.id" class="projet-card">
|
||||
<img v-if="p.thumb" :src="p.thumb" :alt="p.titre" class="projet-thumb" loading="lazy" />
|
||||
<div v-else class="projet-thumb projet-thumb--placeholder">📐</div>
|
||||
|
||||
<h3 style="font-weight: 600; font-size: 0.95rem; margin: 0.5rem 0 0.25rem; color: var(--nav-text);">{{ p.titre }}</h3>
|
||||
<p style="font-size: 0.8rem; color: var(--nav-text-muted); margin-bottom: 0.5rem;">
|
||||
{{ (p.auteurs || []).filter((a: string) => a !== 'Inconnu').join(', ') }}
|
||||
<template v-if="p.ecole && p.ecole !== 'Inconnu'"> · {{ p.ecole }}</template>
|
||||
<template v-if="p.annee && p.annee !== 'Inconnu'"> · {{ p.annee }}</template>
|
||||
</p>
|
||||
<p style="font-size: 0.875rem; line-height: 1.5; color: var(--nav-text); flex: 1; margin-bottom: 0.75rem;">{{ p.description }}</p>
|
||||
|
||||
<a v-if="p.url" :href="p.url" target="_blank" rel="noopener" style="color: var(--nav-primary-solid, #3b6ea5); font-weight: 600; font-size: 0.875rem; text-decoration: none;">
|
||||
Découvrir →
|
||||
</a>
|
||||
<span v-if="p.link_status === 'broken'" style="color: #e67e22; font-size: 0.8rem; display: block; margin-top: 0.25rem;">⚠ Lien d'origine cassé</span>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<p style="margin-top: 2rem; font-size: 0.875rem; color: var(--nav-text-muted);">
|
||||
Tu as un PFE engagé à partager ? <a href="mailto:contact@trans-former.fr" style="color: var(--nav-primary-solid);">Écris-moi</a>.
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { data: pfeData } = await useFetch<{ projets: any[] }>('/data/pfe-engages.json')
|
||||
const projets = computed(() => pfeData.value?.projets ?? [])
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.projets-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 1.25rem;
|
||||
}
|
||||
.projet-card {
|
||||
border: 1px solid var(--nav-bg-alt, #eee);
|
||||
border-radius: 10px;
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--nav-surface);
|
||||
}
|
||||
.projet-thumb {
|
||||
width: 100%;
|
||||
height: 140px;
|
||||
object-fit: cover;
|
||||
border-radius: 6px;
|
||||
background: var(--nav-bg-alt, #f5f5f5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 2rem;
|
||||
}
|
||||
</style>
|
||||
650
components/MediaTabVisuel.vue
Normal file
650
components/MediaTabVisuel.vue
Normal file
@@ -0,0 +1,650 @@
|
||||
<template>
|
||||
<div class="media-visuel">
|
||||
|
||||
<!-- Conteneur principal : carte → toolbar → chatbot (ou bonpote/rag-backend) -->
|
||||
<div class="layout-container">
|
||||
|
||||
<!-- SLOT CARTE D3 (mode carte uniquement) -->
|
||||
<div
|
||||
v-if="contentView === 'carte'"
|
||||
class="carte-slot"
|
||||
:class="[
|
||||
layoutMode === 'split' ? 'carte-split' : '',
|
||||
layoutMode === 'carte-full' ? 'carte-full' : '',
|
||||
layoutMode === 'chatbot-full' ? 'carte-hidden' : '',
|
||||
]"
|
||||
:style="layoutMode === 'split' ? { flexBasis: carteFlexBasis } : {}"
|
||||
style="position: relative;"
|
||||
>
|
||||
<ClientOnly>
|
||||
<CartePensees
|
||||
ref="cartePenseesRef"
|
||||
:data="penseesData"
|
||||
:active="true"
|
||||
@select-auteur="onSelectAuteur"
|
||||
@select-ecole="onSelectEcole"
|
||||
/>
|
||||
<template #fallback>
|
||||
<div class="w-full h-full flex items-center justify-center" style="color: var(--nav-text-muted);">
|
||||
Chargement de la carte...
|
||||
</div>
|
||||
</template>
|
||||
</ClientOnly>
|
||||
|
||||
<!-- Overlay PDF FRACAS -->
|
||||
<div
|
||||
v-if="showFracasPdf"
|
||||
class="fracas-overlay"
|
||||
:style="{ opacity: fracasOpacity / 100 }"
|
||||
>
|
||||
<embed
|
||||
src="/cartes/carte-fracas-bonpote-v2.pdf"
|
||||
type="application/pdf"
|
||||
style="width: 100%; height: 100%;"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- BARRE DE TOGGLE (entre carte et chatbot, toujours visible) -->
|
||||
<div class="layout-toggle-bar shrink-0">
|
||||
|
||||
<!-- Gauche : contrôles layout (seulement en mode carte) -->
|
||||
<template v-if="contentView === 'carte'">
|
||||
<button
|
||||
@click="layoutMode = 'carte-full'"
|
||||
:class="{ active: layoutMode === 'carte-full' }"
|
||||
class="toggle-btn"
|
||||
title="Carte en plein ecran"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/>
|
||||
<line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/>
|
||||
</svg>
|
||||
Carte plein ecran
|
||||
</button>
|
||||
<button
|
||||
v-if="layoutMode !== 'split'"
|
||||
@click="layoutMode = 'split'"
|
||||
class="toggle-btn"
|
||||
title="Vue partagee"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="12" x2="21" y2="12"/>
|
||||
</svg>
|
||||
Vue partagee
|
||||
</button>
|
||||
<button
|
||||
@click="layoutMode = 'chatbot-full'"
|
||||
:class="{ active: layoutMode === 'chatbot-full' }"
|
||||
class="toggle-btn"
|
||||
title="Chatbot plein ecran"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
|
||||
</svg>
|
||||
Chatbot plein ecran
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<!-- Droite : contrôles contenu (toujours, indépendants du layoutMode) -->
|
||||
<div style="margin-left: auto; display: flex; align-items: center; gap: 4px; flex-wrap: wrap;">
|
||||
<!-- Slider opacité PDF -->
|
||||
<input
|
||||
v-if="showFracasPdf && contentView === 'carte'"
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
v-model.number="fracasOpacity"
|
||||
class="opacity-slider"
|
||||
:title="`Opacité ${fracasOpacity}%`"
|
||||
/>
|
||||
|
||||
<!-- CARTE PRINCIPALE -->
|
||||
<button
|
||||
@click="showCarte"
|
||||
:class="{ active: contentView === 'carte' }"
|
||||
class="toggle-btn"
|
||||
title="Vue principale : carte D3 + chatbot"
|
||||
>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="2"/><path d="M12 2a10 10 0 0 0-7.07 17.07M12 2a10 10 0 0 1 7.07 17.07M3.34 7h17.32M3.34 17h17.32"/>
|
||||
</svg>
|
||||
CARTE PRINCIPALE
|
||||
</button>
|
||||
|
||||
<!-- Tickbox PDF + carte des pensées -->
|
||||
<div class="carte-pensees-ctrl">
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="showFracasPdf"
|
||||
class="fracas-check"
|
||||
title="Superposer la carte FRACAS en PDF"
|
||||
/>
|
||||
<button
|
||||
@click="contentView = 'bonpote'"
|
||||
:class="{ active: contentView === 'bonpote' }"
|
||||
class="toggle-btn carte-pensees-btn"
|
||||
title="Carte des pensées écologiques — référence FRACAS Bonpote V2"
|
||||
>
|
||||
📗 carte des pensées écologiques
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- RAG backend -->
|
||||
<button
|
||||
@click="contentView = 'rag-backend'"
|
||||
:class="{ active: contentView === 'rag-backend' }"
|
||||
class="toggle-btn"
|
||||
title="Interface LightRAG backend"
|
||||
>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="3"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14M4.93 4.93a10 10 0 0 0 0 14.14"/>
|
||||
</svg>
|
||||
RAG backend
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- POIGNEE DRAGGABLE (split uniquement) -->
|
||||
<div
|
||||
v-if="contentView === 'carte' && layoutMode === 'split'"
|
||||
class="split-handle"
|
||||
@mousedown.prevent="onHandleMousedown"
|
||||
title="Redimensionner"
|
||||
>
|
||||
<span class="split-handle-grip"></span>
|
||||
</div>
|
||||
|
||||
<!-- SLOT CHATBOT (mode carte uniquement) -->
|
||||
<div
|
||||
v-if="contentView === 'carte'"
|
||||
class="chatbot-slot"
|
||||
:class="[
|
||||
layoutMode === 'split' ? 'chatbot-split' : '',
|
||||
layoutMode === 'chatbot-full' ? 'chatbot-full-mode' : '',
|
||||
layoutMode === 'carte-full' ? 'chatbot-hidden' : '',
|
||||
]"
|
||||
:style="layoutMode === 'split' ? { flexBasis: chatbotFlexBasis } : {}"
|
||||
>
|
||||
<ClientOnly>
|
||||
<ChatbotPensees :auteurContext="chatbotAuteur" :inline="true" />
|
||||
</ClientOnly>
|
||||
</div>
|
||||
|
||||
<!-- VUE BONPOTE -->
|
||||
<div
|
||||
v-if="contentView === 'bonpote'"
|
||||
class="flex-1 overflow-y-auto px-6 py-8"
|
||||
style="max-width: 680px; margin: 0 auto;"
|
||||
>
|
||||
<div class="mb-6">
|
||||
<p class="text-xs font-bold uppercase tracking-widest mb-2" style="color: var(--nav-text-muted);">Reference editoriale</p>
|
||||
<h2 class="text-xl font-bold mb-3" style="color: var(--nav-text);">Carte FRACAS des pensees ecologiques</h2>
|
||||
<p class="text-sm leading-relaxed mb-4" style="color: var(--nav-text);">
|
||||
FRACAS (Familles, Racines et Arpentages des Courants et Alternatives Solidaires) est une carte des ecoles de pensee ecologique publiee par Bonpote en octobre 2024. Elle reference ~140 auteurs et autrices reparti-es en 10 ecoles de pensee, depuis l'ecosocialisme jusqu'a l'ethique environnementale.
|
||||
</p>
|
||||
<p class="text-sm leading-relaxed mb-6" style="color: var(--nav-text);">
|
||||
Le RAG ATIS est construit sur cette reference : chaque auteur ingere dans la bibliotheque correspond a une entree de la carte FRACAS. Les ecoles de pensee, les positions et les couleurs de notre carte sont transposees 1:1 depuis Bonpote V2.
|
||||
</p>
|
||||
<div class="flex flex-col gap-3">
|
||||
<a href="https://bonpote.com/la-carte-des-pensees-ecologiques/"
|
||||
target="_blank" rel="noopener"
|
||||
class="flex items-center gap-3 px-4 py-3 rounded-lg hover:opacity-80 transition-opacity"
|
||||
style="background: var(--nav-primary, #3b6ea5); color: white; font-size: 0.875rem; font-weight: 600; text-decoration: none;">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
|
||||
Lire l'article Bonpote + carte interactive
|
||||
</a>
|
||||
<a href="https://bonpote.com/wp-content/uploads/2024/10/FRACAS_BONPOTE_CARTE_VERSO_V2-OCT2024.pdf"
|
||||
target="_blank" rel="noopener"
|
||||
class="flex items-center gap-3 px-4 py-3 rounded-lg hover:opacity-80 transition-opacity"
|
||||
style="background: var(--nav-bg-alt); color: var(--nav-text); font-size: 0.875rem; font-weight: 500; text-decoration: none;">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
||||
Telecharger le poster PDF (recto/verso)
|
||||
</a>
|
||||
<button
|
||||
@click="showCarte"
|
||||
class="flex items-center gap-3 px-4 py-3 rounded-lg hover:opacity-80 transition-opacity text-left"
|
||||
style="background: var(--nav-bg-alt); color: var(--nav-text); font-size: 0.875rem; font-weight: 500; border: none; cursor: pointer;">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
|
||||
Interroger le RAG ATIS sur ces pensees
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-bold uppercase tracking-widest mb-3" style="color: var(--nav-text-muted);">Les 10 ecoles de pensee (FRACAS V2)</p>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div v-for="ecole in (penseesData?.ecoles ?? [])" :key="ecole.id"
|
||||
class="flex items-start gap-3 px-3 py-2 rounded-lg"
|
||||
style="background: var(--nav-bg-alt);">
|
||||
<span class="w-3 h-3 rounded-full shrink-0 mt-1" :style="`background:${ecole.color};`"></span>
|
||||
<div>
|
||||
<p class="text-sm font-semibold" style="color: var(--nav-text);">{{ ecole.label }}</p>
|
||||
<p class="text-xs mt-0.5 leading-relaxed" style="color: var(--nav-text-muted);">{{ ecole.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- VUE RAG BACKEND -->
|
||||
<div
|
||||
v-if="contentView === 'rag-backend'"
|
||||
style="flex: 1; overflow: hidden; display: flex; flex-direction: column;"
|
||||
>
|
||||
<MediaTabBackend />
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Fiche auteur modal -->
|
||||
<FicheAuteur
|
||||
:open="ficheOpen"
|
||||
:auteurId="ficheAuteurId"
|
||||
:data="penseesData"
|
||||
@close="ficheOpen = false"
|
||||
@interroger-rag="onInterrogerRag"
|
||||
/>
|
||||
|
||||
<!-- Fiche ecole modal -->
|
||||
<FicheEcole
|
||||
:open="ficheEcoleOpen"
|
||||
:ecoleId="ficheEcoleId"
|
||||
:data="penseesData"
|
||||
@close="ficheEcoleOpen = false"
|
||||
@select-auteur="onSelectAuteurFromEcole"
|
||||
@interroger-ecole="onInterrogerEcole"
|
||||
/>
|
||||
|
||||
<!-- Modal info RAG -->
|
||||
<Teleport to="body">
|
||||
<Transition name="backdrop">
|
||||
<div v-if="ragInfoOpen" class="fixed inset-0 z-[2000]" style="background:rgba(26,34,56,0.55);" @click="ragInfoOpen = false" aria-hidden="true" />
|
||||
</Transition>
|
||||
<Transition name="modal">
|
||||
<div v-if="ragInfoOpen" class="fixed z-[2001] left-1/2 flex flex-col"
|
||||
style="top:50%;transform:translate(-50%,-50%);width:min(580px,94vw);max-height:85vh;background:var(--nav-bg);border-radius:14px;box-shadow:0 16px 64px rgba(26,34,56,0.28);overflow:hidden;"
|
||||
role="dialog" aria-modal="true" aria-label="A propos du RAG FRACAS">
|
||||
<div class="flex items-center justify-between px-5 py-4 shrink-0"
|
||||
style="border-bottom:2px solid var(--nav-bg-alt);background:var(--nav-surface);">
|
||||
<h2 class="font-bold text-base" style="color:var(--nav-text);">FRACAS - Bibliotheque des pensees ecologiques</h2>
|
||||
<button @click="ragInfoOpen = false" class="ml-3 shrink-0 flex items-center justify-center w-8 h-8 rounded-full hover:opacity-70"
|
||||
style="background:var(--nav-bg-alt);color:var(--nav-text-muted);" aria-label="Fermer">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex-1 overflow-y-auto px-5 py-4" style="color:var(--nav-text);font-size:0.875rem;line-height:1.6;">
|
||||
<p class="mb-3">Une bibliotheque parlante politisee - des pensees ecologiques de gauche, organisees pour aider a creer une pensee complexe et nuancee, critiquer le recit dominant et soutenir des alternatives concretes et des projets collectifs.</p>
|
||||
<p class="mb-4" style="color:var(--nav-text-muted);font-size:0.8rem;">Projet open source, ouvert a toutes et a tous - <a href="https://bonpote.com/la-carte-des-pensees-ecologiques/" target="_blank" rel="noopener" style="text-decoration:underline;">article + carte FRACAS Bonpote V2</a>.</p>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="p-3 rounded-lg" style="background:var(--nav-bg-alt);">
|
||||
<p class="font-semibold mb-1" style="font-size:0.8rem;color:var(--nav-text-muted);text-transform:uppercase;letter-spacing:0.05em;">Ce qu'est un RAG</p>
|
||||
<p>Les textes sont vectorises dans un espace de 662 dimensions - chaque livre devient un nuage de points semantiques. La proximite entre les points capture la proximite entre les idees, pas les mots.</p>
|
||||
</div>
|
||||
<div class="p-3 rounded-lg" style="background:var(--nav-bg-alt);">
|
||||
<p class="font-semibold mb-1" style="font-size:0.8rem;color:var(--nav-text-muted);text-transform:uppercase;letter-spacing:0.05em;">Chunking intelligent</p>
|
||||
<p>Lors de l'ingestion, nous selectionnons les entites cles (concepts, auteurs, relations entre idees) plutot que de decouper mecaniquement les textes.</p>
|
||||
</div>
|
||||
<div class="p-3 rounded-lg" style="background:var(--nav-bg-alt);">
|
||||
<p class="font-semibold mb-2" style="font-size:0.8rem;color:var(--nav-text-muted);text-transform:uppercase;letter-spacing:0.05em;">Trois couches d'analyse</p>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<div class="flex gap-2"><span class="font-semibold" style="min-width:70px;">Fond</span><span>Les idees, les theses, les arguments - ce qu'on interroge directement.</span></div>
|
||||
<div class="flex gap-2"><span class="font-semibold" style="min-width:70px;">Forme</span><span>Les modeles narratifs, la rhetorique, la construction argumentative.</span></div>
|
||||
<div class="flex gap-2"><span class="font-semibold" style="min-width:70px;">Structure</span><span>L'architecture des livres - comment les auteurs construisent leur pensee.</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface EcoleData { id: string; label: string; description: string; color: string; x_hint: number; y_hint: number }
|
||||
interface LivreRag { slug: string; titre: string; annee: number; couches: string[] }
|
||||
interface AuteurData { id: string; nom: string; dates: string; ecoles: string[]; ecole_principale: string; livres_rag: LivreRag[]; theses_cles: string[]; bio_courte: string }
|
||||
interface PenseesData { meta: any; ecoles: EcoleData[]; auteurs: AuteurData[] }
|
||||
|
||||
type LayoutMode = 'split' | 'carte-full' | 'chatbot-full'
|
||||
type ContentView = 'carte' | 'bonpote' | 'rag-backend'
|
||||
|
||||
const LAYOUT_KEY = 'media-layout-mode'
|
||||
const CONTENT_KEY = 'media-content-view'
|
||||
const SPLIT_RATIO_KEY = 'media-split-ratio'
|
||||
const DEFAULT_SPLIT_RATIO = 0.66
|
||||
|
||||
const ficheOpen = ref(false)
|
||||
const ficheAuteurId = ref<string | null>(null)
|
||||
const ficheEcoleOpen = ref(false)
|
||||
const ficheEcoleId = ref<string | null>(null)
|
||||
const ragInfoOpen = ref(false)
|
||||
const chatbotAuteur = ref<string | null>(null)
|
||||
|
||||
const layoutMode = ref<LayoutMode>('split')
|
||||
const contentView = ref<ContentView>('carte')
|
||||
const cartePenseesRef = ref<{ triggerResize: () => void } | null>(null)
|
||||
|
||||
const showFracasPdf = ref(false)
|
||||
const fracasOpacity = ref(60)
|
||||
|
||||
const penseesData = ref<PenseesData | null>(null)
|
||||
|
||||
const splitRatio = ref(DEFAULT_SPLIT_RATIO)
|
||||
const carteFlexBasis = computed(() => `${splitRatio.value * 100}%`)
|
||||
const chatbotFlexBasis = computed(() => `${(1 - splitRatio.value) * 100}%`)
|
||||
|
||||
let dragStartY = 0
|
||||
let dragStartRatio = DEFAULT_SPLIT_RATIO
|
||||
let containerHeight = 0
|
||||
|
||||
function showCarte() {
|
||||
contentView.value = 'carte'
|
||||
layoutMode.value = 'split'
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem(CONTENT_KEY, 'carte')
|
||||
localStorage.setItem(LAYOUT_KEY, 'split')
|
||||
}
|
||||
nextTick(() => cartePenseesRef.value?.triggerResize())
|
||||
}
|
||||
|
||||
function onHandleMousedown(e: MouseEvent) {
|
||||
dragStartY = e.clientY
|
||||
dragStartRatio = splitRatio.value
|
||||
const container = (e.target as HTMLElement)?.closest('.layout-container') as HTMLElement | null
|
||||
containerHeight = container ? container.clientHeight : window.innerHeight
|
||||
window.addEventListener('mousemove', onHandleMousemove)
|
||||
window.addEventListener('mouseup', onHandleMouseup)
|
||||
}
|
||||
|
||||
function onHandleMousemove(e: MouseEvent) {
|
||||
const delta = e.clientY - dragStartY
|
||||
const newRatio = dragStartRatio + delta / containerHeight
|
||||
splitRatio.value = Math.min(0.80, Math.max(0.20, newRatio))
|
||||
}
|
||||
|
||||
function onHandleMouseup() {
|
||||
window.removeEventListener('mousemove', onHandleMousemove)
|
||||
window.removeEventListener('mouseup', onHandleMouseup)
|
||||
if (typeof window !== 'undefined') localStorage.setItem(SPLIT_RATIO_KEY, String(splitRatio.value))
|
||||
cartePenseesRef.value?.triggerResize()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const savedLayout = localStorage.getItem(LAYOUT_KEY) as LayoutMode | null
|
||||
if (savedLayout && (['split', 'carte-full', 'chatbot-full'] as string[]).includes(savedLayout)) {
|
||||
layoutMode.value = savedLayout
|
||||
}
|
||||
const savedContent = localStorage.getItem(CONTENT_KEY) as ContentView | null
|
||||
if (savedContent && (['carte', 'bonpote', 'rag-backend'] as string[]).includes(savedContent)) {
|
||||
contentView.value = savedContent
|
||||
}
|
||||
const savedRatio = parseFloat(localStorage.getItem(SPLIT_RATIO_KEY) ?? '')
|
||||
if (!isNaN(savedRatio) && savedRatio >= 0.20 && savedRatio <= 0.80) {
|
||||
splitRatio.value = savedRatio
|
||||
}
|
||||
if (!localStorage.getItem('rag-fracas-info-seen')) {
|
||||
ragInfoOpen.value = true
|
||||
localStorage.setItem('rag-fracas-info-seen', '1')
|
||||
}
|
||||
}
|
||||
try {
|
||||
penseesData.value = await $fetch<PenseesData>('/data/auteurs-pensees.json')
|
||||
} catch (e) {
|
||||
console.error('Erreur chargement auteurs-pensees.json', e)
|
||||
}
|
||||
})
|
||||
|
||||
watch(layoutMode, (v) => {
|
||||
if (typeof window !== 'undefined') localStorage.setItem(LAYOUT_KEY, v)
|
||||
if (v === 'split' || v === 'carte-full') {
|
||||
setTimeout(() => cartePenseesRef.value?.triggerResize(), 350)
|
||||
}
|
||||
})
|
||||
|
||||
watch(contentView, (v) => {
|
||||
if (typeof window !== 'undefined') localStorage.setItem(CONTENT_KEY, v)
|
||||
})
|
||||
|
||||
function onSelectAuteur(id: string) {
|
||||
ficheAuteurId.value = id
|
||||
ficheOpen.value = true
|
||||
chatbotAuteur.value = null
|
||||
}
|
||||
|
||||
function onSelectEcole(id: string) {
|
||||
ficheEcoleId.value = id
|
||||
ficheEcoleOpen.value = true
|
||||
}
|
||||
|
||||
function onSelectAuteurFromEcole(auteurId: string) {
|
||||
ficheEcoleOpen.value = false
|
||||
onSelectAuteur(auteurId)
|
||||
}
|
||||
|
||||
function onInterrogerEcole(ecoleId: string) {
|
||||
ficheEcoleOpen.value = false
|
||||
const ecole = penseesData.value?.ecoles.find(e => e.id === ecoleId)
|
||||
chatbotAuteur.value = ecole?.label ?? null
|
||||
if (contentView.value !== 'carte') showCarte()
|
||||
else if (layoutMode.value === 'carte-full') layoutMode.value = 'split'
|
||||
}
|
||||
|
||||
function onInterrogerRag(auteurId: string) {
|
||||
ficheOpen.value = false
|
||||
const auteur = penseesData.value?.auteurs.find(a => a.id === auteurId)
|
||||
chatbotAuteur.value = auteur?.nom ?? null
|
||||
if (contentView.value !== 'carte') showCarte()
|
||||
else if (layoutMode.value === 'carte-full') layoutMode.value = 'split'
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.media-visuel {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.layout-container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* --- Slot carte --- */
|
||||
.carte-slot {
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.carte-split {
|
||||
flex: 0 0 66%;
|
||||
min-height: 0;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.carte-full {
|
||||
flex: 1 1 100%;
|
||||
min-height: 0;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.carte-hidden {
|
||||
flex: 0 0 0;
|
||||
height: 0;
|
||||
opacity: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* --- Overlay PDF FRACAS --- */
|
||||
.fracas-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 50;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* --- Barre de toggle (entre carte et chatbot) --- */
|
||||
.layout-toggle-bar {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 12px;
|
||||
background: var(--nav-bg);
|
||||
border-top: 1px solid rgba(180, 170, 160, 0.22);
|
||||
border-bottom: 1px solid rgba(180, 170, 160, 0.22);
|
||||
min-height: 38px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.toggle-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
background: var(--nav-bg-alt);
|
||||
color: var(--nav-text-muted);
|
||||
border: 1px solid transparent;
|
||||
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.toggle-btn:hover {
|
||||
background: var(--nav-surface);
|
||||
color: var(--nav-text);
|
||||
}
|
||||
|
||||
.toggle-btn.active {
|
||||
background: var(--nav-primary);
|
||||
color: var(--nav-text-on-primary);
|
||||
border-color: var(--nav-primary);
|
||||
}
|
||||
|
||||
/* --- Contrôle fusionné carte des pensées --- */
|
||||
.carte-pensees-ctrl {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(180, 170, 160, 0.3);
|
||||
}
|
||||
|
||||
.fracas-check {
|
||||
margin: 0 2px 0 7px;
|
||||
cursor: pointer;
|
||||
accent-color: var(--nav-primary, #3b6ea5);
|
||||
}
|
||||
|
||||
.carte-pensees-btn {
|
||||
border-radius: 0;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.opacity-slider {
|
||||
width: 80px;
|
||||
cursor: pointer;
|
||||
accent-color: var(--nav-primary, #3b6ea5);
|
||||
}
|
||||
|
||||
/* --- Poignee draggable --- */
|
||||
.split-handle {
|
||||
flex-shrink: 0;
|
||||
height: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: row-resize;
|
||||
background: transparent;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.split-handle:hover {
|
||||
background: rgba(180, 170, 160, 0.18);
|
||||
}
|
||||
|
||||
.split-handle-grip {
|
||||
display: block;
|
||||
width: 32px;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: repeating-linear-gradient(
|
||||
to bottom,
|
||||
rgba(160, 150, 140, 0.55) 0px,
|
||||
rgba(160, 150, 140, 0.55) 1px,
|
||||
transparent 1px,
|
||||
transparent 3px
|
||||
);
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.split-handle { display: none; }
|
||||
}
|
||||
|
||||
/* --- Slot chatbot --- */
|
||||
.chatbot-slot {
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
transition: opacity 0.2s ease;
|
||||
border-top: 1px solid rgba(180, 170, 160, 0.28);
|
||||
}
|
||||
|
||||
.chatbot-split {
|
||||
flex: 0 0 34%;
|
||||
min-height: 0;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.chatbot-full-mode {
|
||||
flex: 1 1 100%;
|
||||
min-height: 0;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.chatbot-hidden {
|
||||
flex: 0 0 0;
|
||||
height: 0;
|
||||
opacity: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* --- Transitions modal RAG info --- */
|
||||
.backdrop-enter-active,.backdrop-leave-active { transition: opacity 0.2s; }
|
||||
.backdrop-enter-from,.backdrop-leave-to { opacity: 0; }
|
||||
.modal-enter-active { transition: opacity 0.2s, transform 0.22s cubic-bezier(0.34,1.56,0.64,1); }
|
||||
.modal-leave-active { transition: opacity 0.18s, transform 0.18s ease-in; }
|
||||
.modal-enter-from { opacity: 0; transform: translate(-50%,-48%) scale(0.94); }
|
||||
.modal-leave-to { opacity: 0; transform: translate(-50%,-48%) scale(0.96); }
|
||||
|
||||
/* --- Mobile --- */
|
||||
@media (max-width: 767px) {
|
||||
.carte-split {
|
||||
flex: 0 0 60vh;
|
||||
height: 60vh;
|
||||
}
|
||||
.chatbot-split {
|
||||
flex: 0 0 calc(40vh - 38px);
|
||||
height: calc(40vh - 38px);
|
||||
}
|
||||
.toggle-btn {
|
||||
font-size: 0.7rem;
|
||||
padding: 3px 7px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
150
components/OutilCard.vue
Normal file
150
components/OutilCard.vue
Normal file
@@ -0,0 +1,150 @@
|
||||
<template>
|
||||
<component
|
||||
:is="url ? 'a' : 'div'"
|
||||
v-bind="url ? { href: url, target: '_blank', rel: 'noopener noreferrer' } : {}"
|
||||
class="outil-card"
|
||||
:class="{ 'outil-card--link': !!url, 'outil-card--disabled': !url }"
|
||||
>
|
||||
<div class="outil-card__header">
|
||||
<span class="outil-card__icon" aria-hidden="true">{{ icon }}</span>
|
||||
<span :class="['outil-card__badge', `outil-card__badge--${tag}`]">{{ tagLabel }}</span>
|
||||
</div>
|
||||
<h3 class="outil-card__titre">{{ titre }}</h3>
|
||||
<p class="outil-card__desc">{{ description }}</p>
|
||||
<span v-if="cta && url" class="outil-card__cta">{{ cta }}</span>
|
||||
<span v-else-if="!url" class="outil-card__cta outil-card__cta--disabled">Bientôt disponible</span>
|
||||
</component>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
icon?: string
|
||||
titre: string
|
||||
url?: string | null
|
||||
description?: string
|
||||
cta?: string
|
||||
tag?: string
|
||||
}>()
|
||||
|
||||
const tagLabels: Record<string, string> = {
|
||||
'outil-aep': 'Outil AEP',
|
||||
'inspiration-externe': 'Inspiration',
|
||||
'disponible': 'Disponible',
|
||||
'recommande': 'Recommandé',
|
||||
'a-venir': 'À venir',
|
||||
}
|
||||
|
||||
const tagLabel = computed(() => props.tag ? (tagLabels[props.tag] ?? props.tag) : '')
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.outil-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: 1rem 1.25rem;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--nav-bg-alt);
|
||||
background: var(--nav-surface);
|
||||
text-decoration: none;
|
||||
color: var(--nav-text);
|
||||
transition: box-shadow 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.outil-card--link:hover {
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
|
||||
border-color: var(--nav-primary-solid);
|
||||
}
|
||||
|
||||
.outil-card--disabled {
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.outil-card__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.outil-card__icon {
|
||||
font-size: 1.3rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.outil-card__badge {
|
||||
font-size: 0.65rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
padding: 2px 7px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.outil-card__badge--outil-aep {
|
||||
background: #d1fae5;
|
||||
color: #065f46;
|
||||
}
|
||||
.outil-card__badge--inspiration-externe {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
.outil-card__badge--disponible {
|
||||
background: #d1fae5;
|
||||
color: #065f46;
|
||||
}
|
||||
.outil-card__badge--recommande {
|
||||
background: #dbeafe;
|
||||
color: #1e40af;
|
||||
}
|
||||
.outil-card__badge--a-venir {
|
||||
background: var(--nav-bg-alt);
|
||||
color: var(--nav-text-muted);
|
||||
}
|
||||
|
||||
.outil-card__titre {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: var(--nav-text);
|
||||
margin: 0;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.outil-card__desc {
|
||||
font-size: 0.82rem;
|
||||
color: var(--nav-text-muted);
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.outil-card__cta {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
color: var(--nav-primary-solid);
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.outil-card__cta--disabled {
|
||||
color: var(--nav-text-muted);
|
||||
font-weight: 400;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Dark mode badge overrides */
|
||||
:global(.dark) .outil-card__badge--outil-aep {
|
||||
background: #064e3b;
|
||||
color: #a7f3d0;
|
||||
}
|
||||
:global(.dark) .outil-card__badge--inspiration-externe {
|
||||
background: #78350f;
|
||||
color: #fde68a;
|
||||
}
|
||||
:global(.dark) .outil-card__badge--disponible {
|
||||
background: #064e3b;
|
||||
color: #a7f3d0;
|
||||
}
|
||||
:global(.dark) .outil-card__badge--recommande {
|
||||
background: #1e3a5f;
|
||||
color: #93c5fd;
|
||||
}
|
||||
</style>
|
||||
156
components/SimulateurFeature.vue
Normal file
156
components/SimulateurFeature.vue
Normal file
@@ -0,0 +1,156 @@
|
||||
<template>
|
||||
<component
|
||||
:is="url ? 'a' : 'div'"
|
||||
v-bind="url ? { href: url, target: '_blank', rel: 'noopener noreferrer' } : {}"
|
||||
class="simu-feature"
|
||||
:class="{ 'simu-feature--link': !!url }"
|
||||
>
|
||||
<div class="simu-feature__inner">
|
||||
<div class="simu-feature__left">
|
||||
<span class="simu-feature__icon" aria-hidden="true">{{ icon }}</span>
|
||||
<div class="simu-feature__body">
|
||||
<div class="simu-feature__header">
|
||||
<h3 class="simu-feature__titre">{{ titre }}</h3>
|
||||
<span v-if="tag" :class="['simu-feature__badge', `simu-feature__badge--${tag}`]">{{ tagLabel }}</span>
|
||||
</div>
|
||||
<p class="simu-feature__desc">{{ description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span v-if="cta && url" class="simu-feature__cta">{{ cta }}</span>
|
||||
</div>
|
||||
</component>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
icon?: string
|
||||
titre: string
|
||||
url?: string | null
|
||||
description?: string
|
||||
cta?: string
|
||||
tag?: string
|
||||
}>()
|
||||
|
||||
const tagLabels: Record<string, string> = {
|
||||
'outil-aep': 'Outil AEP',
|
||||
'inspiration-externe': 'Inspiration externe',
|
||||
'disponible': 'Disponible',
|
||||
'recommande': 'Recommandé',
|
||||
'a-venir': 'À venir',
|
||||
}
|
||||
|
||||
const tagLabel = computed(() => props.tag ? (tagLabels[props.tag] ?? props.tag) : '')
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.simu-feature {
|
||||
display: block;
|
||||
padding: 1.5rem 1.75rem;
|
||||
border-radius: 14px;
|
||||
border: 1.5px solid var(--nav-bg-alt);
|
||||
background: var(--nav-surface);
|
||||
text-decoration: none;
|
||||
color: var(--nav-text);
|
||||
transition: box-shadow 0.2s, border-color 0.2s, transform 0.15s;
|
||||
}
|
||||
|
||||
.simu-feature--link:hover {
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
|
||||
border-color: var(--nav-primary-solid);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.simu-feature__inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.simu-feature__left {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.simu-feature__icon {
|
||||
font-size: 2rem;
|
||||
line-height: 1;
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.simu-feature__body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.simu-feature__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
.simu-feature__titre {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 700;
|
||||
color: var(--nav-text);
|
||||
margin: 0;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.simu-feature__badge {
|
||||
font-size: 0.65rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
background: #d1fae5;
|
||||
color: #065f46;
|
||||
}
|
||||
|
||||
.simu-feature__badge--inspiration-externe {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.simu-feature__desc {
|
||||
font-size: 0.88rem;
|
||||
color: var(--nav-text-muted);
|
||||
margin: 0;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.simu-feature__cta {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.6rem 1.25rem;
|
||||
background: var(--nav-primary-solid);
|
||||
color: var(--nav-text-on-primary);
|
||||
border-radius: 8px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
transition: opacity 0.15s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.simu-feature--link:hover .simu-feature__cta {
|
||||
opacity: 0.88;
|
||||
}
|
||||
|
||||
:global(.dark) .simu-feature__badge {
|
||||
background: #064e3b;
|
||||
color: #a7f3d0;
|
||||
}
|
||||
:global(.dark) .simu-feature__badge--inspiration-externe {
|
||||
background: #78350f;
|
||||
color: #fde68a;
|
||||
}
|
||||
</style>
|
||||
201
components/TreeASCII.vue
Normal file
201
components/TreeASCII.vue
Normal file
@@ -0,0 +1,201 @@
|
||||
<template>
|
||||
<ul class="tree-ascii" :class="{ 'tree-ascii--root': depth === 0 }" :style="{ '--depth': depth }">
|
||||
<li
|
||||
v-for="(node, i) in tree.children"
|
||||
:key="i"
|
||||
class="tree-ascii__node"
|
||||
>
|
||||
<!-- Nœud avec enfants : bouton toggle -->
|
||||
<template v-if="node.children && node.children.length">
|
||||
<button
|
||||
class="tree-ascii__branch"
|
||||
:aria-expanded="!!open[i]"
|
||||
@click="toggle(i)"
|
||||
>
|
||||
<span class="tree-ascii__chevron" aria-hidden="true">{{ open[i] ? '▼' : '▶' }}</span>
|
||||
<span class="tree-ascii__label">{{ node.name }}</span>
|
||||
<span class="tree-ascii__count">({{ node.children.length }})</span>
|
||||
</button>
|
||||
<TreeASCII
|
||||
v-if="open[i]"
|
||||
:tree="node"
|
||||
:depth="depth + 1"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- Feuille avec URL : lien cliquable -->
|
||||
<template v-else-if="node.url">
|
||||
<a
|
||||
:href="node.url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="tree-ascii__leaf tree-ascii__leaf--link"
|
||||
>
|
||||
<span class="tree-ascii__prefix" aria-hidden="true">└─</span>
|
||||
<span class="tree-ascii__label">{{ node.name }}</span>
|
||||
<span v-if="node.desc" class="tree-ascii__desc"> — {{ node.desc }}</span>
|
||||
<svg class="tree-ascii__ext" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
|
||||
<polyline points="15 3 21 3 21 9"/>
|
||||
<line x1="10" y1="14" x2="21" y2="3"/>
|
||||
</svg>
|
||||
</a>
|
||||
</template>
|
||||
|
||||
<!-- Feuille sans URL -->
|
||||
<template v-else>
|
||||
<span class="tree-ascii__leaf">
|
||||
<span class="tree-ascii__prefix" aria-hidden="true">└─</span>
|
||||
<span class="tree-ascii__label">{{ node.name }}</span>
|
||||
<span v-if="node.desc" class="tree-ascii__desc"> — {{ node.desc }}</span>
|
||||
</span>
|
||||
</template>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
export interface TreeNode {
|
||||
name: string
|
||||
url?: string
|
||||
desc?: string
|
||||
children?: TreeNode[]
|
||||
}
|
||||
|
||||
export interface TreeData {
|
||||
name?: string
|
||||
children?: TreeNode[]
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
tree: TreeData
|
||||
depth?: number
|
||||
}>(), {
|
||||
depth: 0
|
||||
})
|
||||
|
||||
// Toutes les branches fermées par défaut
|
||||
const open = ref<Record<number, boolean>>({})
|
||||
|
||||
function toggle(i: number) {
|
||||
open.value[i] = !open.value[i]
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tree-ascii {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
font-family: 'JetBrains Mono', 'Fira Code', 'Courier New', monospace;
|
||||
font-size: 0.82rem;
|
||||
padding-left: calc(var(--depth, 0) * 1.25rem + 0.5rem);
|
||||
}
|
||||
|
||||
.tree-ascii--root {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.tree-ascii__node {
|
||||
margin: 2px 0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* Bouton branche (nœud avec enfants) */
|
||||
.tree-ascii__branch {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
color: var(--nav-text);
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
font-weight: 600;
|
||||
transition: background 0.1s, color 0.1s;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.tree-ascii__branch:hover {
|
||||
background: var(--nav-bg-alt);
|
||||
color: var(--nav-primary-solid);
|
||||
}
|
||||
|
||||
.tree-ascii__chevron {
|
||||
font-size: 0.65rem;
|
||||
color: var(--nav-text-muted);
|
||||
width: 12px;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tree-ascii__count {
|
||||
font-size: 0.7rem;
|
||||
color: var(--nav-text-muted);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* Feuille */
|
||||
.tree-ascii__leaf {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 0.25rem;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
text-decoration: none;
|
||||
color: var(--nav-text-muted);
|
||||
}
|
||||
|
||||
.tree-ascii__leaf--link {
|
||||
color: var(--nav-text);
|
||||
cursor: pointer;
|
||||
transition: background 0.1s, color 0.1s;
|
||||
}
|
||||
|
||||
.tree-ascii__leaf--link:hover {
|
||||
background: var(--nav-bg-alt);
|
||||
color: var(--nav-primary-solid);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.tree-ascii__prefix {
|
||||
color: var(--nav-text-muted);
|
||||
opacity: 0.5;
|
||||
font-size: 0.75rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tree-ascii__label {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.tree-ascii__leaf--link .tree-ascii__label {
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.tree-ascii__desc {
|
||||
color: var(--nav-text-muted);
|
||||
font-size: 0.78rem;
|
||||
font-style: italic;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 60ch;
|
||||
}
|
||||
|
||||
.tree-ascii__ext {
|
||||
opacity: 0.4;
|
||||
flex-shrink: 0;
|
||||
margin-left: 2px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.tree-ascii__desc {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -23,7 +23,6 @@ export default defineNuxtConfig({
|
||||
codevPassword: 'merci', // NUXT_CODEV_PASSWORD - défaut "merci", overridable
|
||||
codevBaseId: '', // NUXT_CODEV_BASE_ID - base NocoDB (ex: pipilvsi7dibo80)
|
||||
codevAdminPassword: 'admin2026', // NUXT_CODEV_ADMIN_PASSWORD
|
||||
ragPeUrl: process.env.NUXT_RAG_PE_URL || 'http://localhost:9621',
|
||||
},
|
||||
|
||||
// Leaflet ne fonctionne pas en SSR — forcer le rendu côté client
|
||||
|
||||
@@ -128,12 +128,6 @@
|
||||
: '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 -->
|
||||
@@ -225,11 +219,6 @@
|
||||
: '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">
|
||||
@@ -414,11 +403,6 @@
|
||||
@update:modelValue="chatbotOpen = $event"
|
||||
/>
|
||||
|
||||
<!-- ═══════════════════════════════════════ CHATBOT PENSEES (desktop, tous onglets) -->
|
||||
<ClientOnly>
|
||||
<ChatbotPensees />
|
||||
</ClientOnly>
|
||||
|
||||
<!-- ═══════════════════════════════════════ POP-UP MISSION RÉSEAUX AEP -->
|
||||
<button
|
||||
class="reseaux-info-btn"
|
||||
|
||||
@@ -201,19 +201,10 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filtres FONCTION — chips flex-wrap + toggle collapse -->
|
||||
<!-- Filtres FONCTION — chips flex-wrap -->
|
||||
<div class="mt-2">
|
||||
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 4px;">
|
||||
<span class="text-xs font-bold uppercase tracking-wide" style="color: var(--nav-text-muted);">
|
||||
FONCTION
|
||||
<span v-if="fonctions.length" style="font-weight: 400; text-transform: none; letter-spacing: 0; font-size: 0.65rem; margin-left: 4px;">({{ fonctions.length }} active{{ fonctions.length > 1 ? 's' : '' }})</span>
|
||||
</span>
|
||||
<button
|
||||
@click="mobileFonctionsOpen = !mobileFonctionsOpen"
|
||||
style="font-size: 0.65rem; color: var(--nav-text-muted); background: none; border: none; cursor: pointer; text-decoration: underline; padding: 0; white-space: nowrap;"
|
||||
>{{ mobileFonctionsOpen || fonctions.length ? (mobileFonctionsOpen ? 'Replier' : 'Afficher') : 'Fonctions (' + FONCTIONS.length + ')' }}</button>
|
||||
</div>
|
||||
<div v-if="mobileFonctionsOpen || fonctions.length" class="flex flex-wrap gap-1">
|
||||
<span class="text-xs font-bold uppercase tracking-wide block mb-1" style="color: var(--nav-text-muted);">FONCTION</span>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<span
|
||||
v-for="fn in FONCTIONS"
|
||||
:key="fn"
|
||||
@@ -374,7 +365,6 @@ const ficheModalOpen = ref(false)
|
||||
const ficheModalId = ref<number | null>(null)
|
||||
const mobileMapView = ref<'metropole' | 'outremer'>('metropole')
|
||||
const missionOpen = ref(false)
|
||||
const mobileFonctionsOpen = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
try {
|
||||
|
||||
347
pages/media.vue
347
pages/media.vue
@@ -1,329 +1,56 @@
|
||||
<template>
|
||||
<div class="media-page" style="background: var(--nav-bg);">
|
||||
<nav class="subtabs" style="display:flex; gap:0; border-bottom: 1px solid var(--nav-bg-alt); background: var(--nav-surface); padding: 0 1rem;">
|
||||
<button
|
||||
:class="['subtab-btn', { active: tab === 'visuel' }]"
|
||||
@click="tab = 'visuel'"
|
||||
>
|
||||
📚 bibliothèque des pensées écologiques
|
||||
</button>
|
||||
<button
|
||||
:class="['subtab-btn', { active: tab === 'projets' }]"
|
||||
@click="tab = 'projets'"
|
||||
>
|
||||
📐 Projets
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<!-- 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"
|
||||
/>
|
||||
|
||||
<MediaTabVisuel v-if="tab === 'visuel'" />
|
||||
<MediaTabProjets v-else-if="tab === 'projets'" />
|
||||
</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[] }
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
type LayoutMode = 'split' | 'carte-full' | 'chatbot-full'
|
||||
const tab = ref<'visuel' | 'projets'>(
|
||||
(['visuel', 'projets'].includes(route.query.tab as string)
|
||||
? route.query.tab as 'visuel' | 'projets'
|
||||
: 'visuel')
|
||||
)
|
||||
|
||||
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)
|
||||
}
|
||||
watch(tab, (newTab) => {
|
||||
router.replace({ query: { ...route.query, tab: newTab } })
|
||||
})
|
||||
|
||||
// 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' })
|
||||
useHead({ title: 'AEP - Media' })
|
||||
</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;
|
||||
.media-page { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
|
||||
.subtabs { display: flex; gap: 0; flex-shrink: 0; }
|
||||
.subtab-btn {
|
||||
padding: 10px 18px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
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;
|
||||
}
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
}
|
||||
.subtab-btn:hover { color: var(--nav-text); }
|
||||
.subtab-btn.active { color: var(--nav-primary-solid); border-bottom-color: var(--nav-primary-solid); font-weight: 600; }
|
||||
</style>
|
||||
|
||||
533
pages/outils.vue
Normal file
533
pages/outils.vue
Normal file
@@ -0,0 +1,533 @@
|
||||
<template>
|
||||
<div class="outils-page">
|
||||
|
||||
<!-- ══════════════════════ EN-TÊTE PAGE ══════════════════════ -->
|
||||
<header class="outils-header">
|
||||
<div class="outils-header__inner">
|
||||
<div class="outils-header__icon-wrap" aria-hidden="true">
|
||||
<img src="/icons/outils-wrench.svg" alt="" class="outils-header__icon" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="outils-header__title">Outils</h1>
|
||||
<p class="outils-header__intro">
|
||||
En tant qu'architecte, on jongle avec une multitude d'outils — simulation, dessin,
|
||||
calcul, recherche, partage. Les mutualiser, se conseiller dessus, savoir lequel
|
||||
utiliser quand : c'est une forme d'entraide concrète. Voici ceux que je propose
|
||||
dans un premier temps. Chacun peut contribuer pour enrichir cette boîte à outils
|
||||
commune.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="outils-main">
|
||||
|
||||
<!-- ══════════════ SECTION 1 — Simulateurs métier ══════════════ -->
|
||||
<section class="outils-section outils-section--simulateurs" aria-labelledby="sec-simulateurs">
|
||||
<h2 id="sec-simulateurs" class="outils-section__title">
|
||||
<span aria-hidden="true">🧮</span> Simulateurs métier
|
||||
</h2>
|
||||
<p class="outils-section__subtitle">Créés par AEP — outils de calcul situés.</p>
|
||||
|
||||
<div class="simu-grid">
|
||||
<SimulateurFeature
|
||||
v-for="s in outils?.simulateurs"
|
||||
:key="s.id"
|
||||
:icon="s.icon"
|
||||
:titre="s.titre"
|
||||
:url="s.url"
|
||||
:description="s.description"
|
||||
:cta="s.cta"
|
||||
:tag="s.tag"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Inspirations -->
|
||||
<div v-if="outils?.simulateurs_inspirations?.length" class="simu-inspirations">
|
||||
<p class="simu-inspirations__label">Inspiration externe</p>
|
||||
<div class="outil-cards-grid">
|
||||
<OutilCard
|
||||
v-for="s in outils.simulateurs_inspirations"
|
||||
:key="s.id"
|
||||
:icon="s.icon"
|
||||
:titre="s.titre"
|
||||
:url="s.url"
|
||||
:description="s.description"
|
||||
:tag="s.tag"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ══════════════ SECTION 2 — Open source recommandés ══════════════ -->
|
||||
<section class="outils-section outils-section--opensource" aria-labelledby="sec-opensource">
|
||||
<h2 id="sec-opensource" class="outils-section__title">
|
||||
<span aria-hidden="true">🔧</span> Outils tech open source
|
||||
</h2>
|
||||
<p class="outils-section__subtitle">Quelques recommandations directes. Le cœur de l'onglet, c'est la section FMHY plus bas.</p>
|
||||
|
||||
<div class="outil-cards-grid">
|
||||
<OutilCard
|
||||
v-for="outil in outils?.opensource"
|
||||
:key="outil.id"
|
||||
:icon="outil.icon"
|
||||
:titre="outil.titre"
|
||||
:url="outil.url"
|
||||
:description="outil.description"
|
||||
:tag="outil.tag"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ══════════════ SECTION 3 — Bifurcation ══════════════ -->
|
||||
<section class="outils-section" aria-labelledby="sec-bifurcation">
|
||||
<h2 id="sec-bifurcation" class="outils-section__title">
|
||||
<span aria-hidden="true">🌿</span> Bifurcation post-études d'archi
|
||||
</h2>
|
||||
<p class="outils-section__desc">
|
||||
{{ outils?.bifurcation?.intro }}
|
||||
</p>
|
||||
|
||||
<!-- 3.1 Vidéos OFQA -->
|
||||
<div class="bifurcation-block">
|
||||
<h3 class="bifurcation-block__title">Série vidéo OFQA / ENSA-PB</h3>
|
||||
<ul class="ofqa-list">
|
||||
<li
|
||||
v-for="ep in outils?.bifurcation?.videos_ofqa"
|
||||
:key="ep.ep"
|
||||
class="ofqa-list__item"
|
||||
>
|
||||
<component
|
||||
:is="ep.url ? 'a' : 'span'"
|
||||
v-bind="ep.url ? { href: ep.url, target: '_blank', rel: 'noopener noreferrer' } : {}"
|
||||
class="ofqa-list__link"
|
||||
:class="{ 'ofqa-list__link--disabled': !ep.url }"
|
||||
>
|
||||
<span class="ofqa-list__ep">EP/{{ ep.ep }}</span>
|
||||
<span class="ofqa-list__titre">{{ ep.titre }}</span>
|
||||
<span class="ofqa-list__personnes">— {{ ep.personnes }}</span>
|
||||
<span v-if="ep.note" class="ofqa-list__note">({{ ep.note }})</span>
|
||||
</component>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- 3.2 Coalition -->
|
||||
<div v-if="outils?.bifurcation?.coalition_ensa_pb" class="bifurcation-block bifurcation-block--coalition">
|
||||
<h3 class="bifurcation-block__title">{{ outils.bifurcation.coalition_ensa_pb.titre }}</h3>
|
||||
<p class="bifurcation-block__desc">{{ outils.bifurcation.coalition_ensa_pb.description }}</p>
|
||||
</div>
|
||||
|
||||
<!-- 3.3 Ressources externes -->
|
||||
<div v-if="outils?.bifurcation?.ressources_externes?.length" class="bifurcation-block">
|
||||
<h3 class="bifurcation-block__title">Ressources externes</h3>
|
||||
<div class="outil-cards-grid">
|
||||
<OutilCard
|
||||
v-for="r in outils.bifurcation.ressources_externes"
|
||||
:key="r.id"
|
||||
:icon="r.icon"
|
||||
:titre="r.titre"
|
||||
:url="r.url"
|
||||
:description="r.description"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ══════════════ SECTION 4 — FMHY (cœur de la page) ══════════════ -->
|
||||
<section class="outils-section outils-section--fmhy" aria-labelledby="sec-fmhy">
|
||||
<h2 id="sec-fmhy" class="outils-section__title">
|
||||
<span aria-hidden="true">🌳</span> Bibliothèque de ressources libres
|
||||
</h2>
|
||||
<p class="outils-section__desc">
|
||||
Le vrai trésor de l'onglet Outils. FMHY (Free Media Heck Yeah) est la plus grosse
|
||||
base communautaire d'outils, services et ressources libres/gratuits du web. J'en ai
|
||||
curé ~50 entrées pertinentes pour un architecte : IA, lecture, dev, vie privée,
|
||||
formation, médias. Clique sur les branches pour explorer.
|
||||
</p>
|
||||
|
||||
<div class="fmhy-tree-wrap">
|
||||
<div v-if="fmhyPending" class="fmhy-loading" aria-label="Chargement…">
|
||||
<span>Chargement…</span>
|
||||
</div>
|
||||
<div v-else-if="fmhyError" class="fmhy-error">
|
||||
Impossible de charger les ressources. <a href="https://fmhy.net/" target="_blank" rel="noopener noreferrer">Explorer fmhy.net →</a>
|
||||
</div>
|
||||
<TreeASCII v-else-if="fmhyData" :tree="fmhyData" :depth="0" />
|
||||
</div>
|
||||
|
||||
<div class="fmhy-footer">
|
||||
<a href="https://fmhy.net/" target="_blank" rel="noopener noreferrer" class="fmhy-footer__link">
|
||||
Explorer tout l'arbre → fmhy.net
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ══════════════ SECTION 5 — Placeholder login ══════════════ -->
|
||||
<section class="outils-section outils-section--placeholder" aria-labelledby="sec-logiciels">
|
||||
<div class="placeholder-block">
|
||||
<span class="placeholder-block__badge">Bientôt — nécessite un compte</span>
|
||||
<h2 id="sec-logiciels" class="placeholder-block__title">{{ outils?.section_5_placeholder?.titre }}</h2>
|
||||
<p class="placeholder-block__desc">{{ outils?.section_5_placeholder?.description }}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ══════════════ FOOTER CONTRIBUTION ══════════════ -->
|
||||
<footer class="outils-footer">
|
||||
<p class="outils-footer__text">
|
||||
{{ outils?.footer_contribution }}
|
||||
</p>
|
||||
</footer>
|
||||
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// Chargement des données
|
||||
const { data: outils } = await useFetch('/data/outils.json')
|
||||
const { data: fmhyData, pending: fmhyPending, error: fmhyError } = await useFetch('/data/fmhy-curated.json')
|
||||
|
||||
useSeoMeta({
|
||||
title: 'Outils — AEP',
|
||||
description: 'Outils partagés entre architectes : simulateurs, open source, ressources libres FMHY, bifurcation post-études.'
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* ── Layout global ──────────────────────────────────────────── */
|
||||
.outils-page {
|
||||
max-width: 860px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem 1.5rem 4rem;
|
||||
color: var(--nav-text);
|
||||
}
|
||||
|
||||
/* ── Header ─────────────────────────────────────────────────── */
|
||||
.outils-header {
|
||||
margin-bottom: 2.5rem;
|
||||
}
|
||||
|
||||
.outils-header__inner {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.outils-header__icon-wrap {
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
border-radius: 10px;
|
||||
background: var(--nav-bg-alt);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
padding: 0.6rem;
|
||||
}
|
||||
|
||||
.outils-header__icon {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.outils-header__title {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
margin: 0 0 0.5rem;
|
||||
color: var(--nav-text);
|
||||
}
|
||||
|
||||
.outils-header__intro {
|
||||
font-size: 0.9rem;
|
||||
color: var(--nav-text-muted);
|
||||
line-height: 1.65;
|
||||
margin: 0;
|
||||
max-width: 70ch;
|
||||
}
|
||||
|
||||
/* ── Sections ───────────────────────────────────────────────── */
|
||||
.outils-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3rem;
|
||||
}
|
||||
|
||||
.outils-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.outils-section__title {
|
||||
font-size: 1.15rem;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
color: var(--nav-text);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 1.5px solid var(--nav-bg-alt);
|
||||
}
|
||||
|
||||
.outils-section__subtitle {
|
||||
font-size: 0.82rem;
|
||||
color: var(--nav-text-muted);
|
||||
margin: -0.5rem 0 0;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.outils-section__desc {
|
||||
font-size: 0.88rem;
|
||||
color: var(--nav-text-muted);
|
||||
line-height: 1.65;
|
||||
margin: 0;
|
||||
max-width: 72ch;
|
||||
}
|
||||
|
||||
/* ── Simulateurs ────────────────────────────────────────────── */
|
||||
.outils-section--simulateurs {
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.simu-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.simu-inspirations {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.simu-inspirations__label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--nav-text-muted);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
/* ── Cards grid ─────────────────────────────────────────────── */
|
||||
.outil-cards-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
/* ── Section FMHY ───────────────────────────────────────────── */
|
||||
.outils-section--fmhy {
|
||||
background: var(--nav-bg-alt);
|
||||
border-radius: 14px;
|
||||
padding: 1.75rem;
|
||||
gap: 1.25rem;
|
||||
margin: 0 -0.25rem;
|
||||
}
|
||||
|
||||
.fmhy-tree-wrap {
|
||||
background: var(--nav-surface);
|
||||
border: 1px solid var(--nav-bg-alt);
|
||||
border-radius: 10px;
|
||||
padding: 1.25rem 1.5rem;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.fmhy-loading,
|
||||
.fmhy-error {
|
||||
font-size: 0.85rem;
|
||||
color: var(--nav-text-muted);
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
.fmhy-error a {
|
||||
color: var(--nav-primary-solid);
|
||||
}
|
||||
|
||||
.fmhy-footer {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.fmhy-footer__link {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
color: var(--nav-primary-solid);
|
||||
text-decoration: none;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.fmhy-footer__link:hover {
|
||||
opacity: 0.75;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* ── Bifurcation ────────────────────────────────────────────── */
|
||||
.bifurcation-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.bifurcation-block__title {
|
||||
font-size: 0.92rem;
|
||||
font-weight: 600;
|
||||
color: var(--nav-text);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.bifurcation-block__desc {
|
||||
font-size: 0.84rem;
|
||||
color: var(--nav-text-muted);
|
||||
margin: 0;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.bifurcation-block--coalition {
|
||||
background: var(--nav-bg-alt);
|
||||
border-radius: 8px;
|
||||
padding: 0.875rem 1rem;
|
||||
}
|
||||
|
||||
/* ── Liste OFQA ─────────────────────────────────────────────── */
|
||||
.ofqa-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.ofqa-list__item {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.ofqa-list__link {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 0.4rem;
|
||||
flex-wrap: wrap;
|
||||
padding: 3px 6px;
|
||||
border-radius: 5px;
|
||||
font-size: 0.84rem;
|
||||
text-decoration: none;
|
||||
color: var(--nav-text);
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.ofqa-list__link:not(.ofqa-list__link--disabled):hover {
|
||||
background: var(--nav-bg-alt);
|
||||
color: var(--nav-primary-solid);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.ofqa-list__link--disabled {
|
||||
color: var(--nav-text-muted);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.ofqa-list__ep {
|
||||
font-family: 'JetBrains Mono', 'Courier New', monospace;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--nav-text-muted);
|
||||
flex-shrink: 0;
|
||||
min-width: 4.5rem;
|
||||
}
|
||||
|
||||
.ofqa-list__titre {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.ofqa-list__personnes {
|
||||
color: var(--nav-text-muted);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.ofqa-list__note {
|
||||
color: var(--nav-text-muted);
|
||||
font-size: 0.78rem;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* ── Section 5 placeholder ──────────────────────────────────── */
|
||||
.outils-section--placeholder {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.placeholder-block {
|
||||
border: 1.5px dashed var(--nav-bg-alt);
|
||||
border-radius: 12px;
|
||||
padding: 1.25rem 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.placeholder-block__badge {
|
||||
font-size: 0.68rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--nav-text-muted);
|
||||
}
|
||||
|
||||
.placeholder-block__title {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
color: var(--nav-text);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.placeholder-block__desc {
|
||||
font-size: 0.84rem;
|
||||
color: var(--nav-text-muted);
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ── Footer ─────────────────────────────────────────────────── */
|
||||
.outils-footer {
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--nav-bg-alt);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.outils-footer__text {
|
||||
font-size: 0.84rem;
|
||||
color: var(--nav-text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ── Mobile ─────────────────────────────────────────────────── */
|
||||
@media (max-width: 640px) {
|
||||
.outils-page {
|
||||
padding: 1.25rem 1rem 4rem;
|
||||
}
|
||||
|
||||
.outils-header__inner {
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.outils-header__title {
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
|
||||
.outil-cards-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.outils-section--fmhy {
|
||||
padding: 1.25rem 1rem;
|
||||
margin: 0 -0.5rem;
|
||||
}
|
||||
|
||||
.fmhy-tree-wrap {
|
||||
padding: 0.875rem 0.75rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
3
pages/rag.vue
Normal file
3
pages/rag.vue
Normal file
@@ -0,0 +1,3 @@
|
||||
<script setup lang="ts">
|
||||
navigateTo('/media', { redirectCode: 301 })
|
||||
</script>
|
||||
16873
public/cartes/carte-fracas-bonpote-v2.pdf
Normal file
16873
public/cartes/carte-fracas-bonpote-v2.pdf
Normal file
File diff suppressed because one or more lines are too long
@@ -1,491 +0,0 @@
|
||||
{
|
||||
"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."
|
||||
}
|
||||
]
|
||||
}
|
||||
102
public/data/fmhy-curated.json
Normal file
102
public/data/fmhy-curated.json
Normal file
@@ -0,0 +1,102 @@
|
||||
{
|
||||
"name": "FMHY — Sélection Architecte",
|
||||
"description": "~50 ressources curées depuis FMHY (Free Media Heck Yeah) — pertinentes pour un architecte, un praticien de la transition, un créateur solo.",
|
||||
"children": [
|
||||
{
|
||||
"name": "IA & Outils cognitifs",
|
||||
"children": [
|
||||
{ "name": "ChatGPT (OpenAI)", "url": "https://chat.openai.com/", "desc": "LLM généraliste, référence." },
|
||||
{ "name": "Claude (Anthropic)", "url": "https://claude.ai/", "desc": "Excellent pour rédaction longue et analyse de documents." },
|
||||
{ "name": "Mistral Le Chat", "url": "https://chat.mistral.ai/", "desc": "LLM français, souverain, gratuit." },
|
||||
{ "name": "Perplexity", "url": "https://www.perplexity.ai/", "desc": "Moteur de recherche IA avec sources citées." },
|
||||
{ "name": "Hugging Face", "url": "https://huggingface.co/", "desc": "Hub de modèles open source. Indispensable." },
|
||||
{ "name": "LM Studio", "url": "https://lmstudio.ai/", "desc": "Faire tourner des LLM localement, sans cloud." }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Lecture & Documentation",
|
||||
"children": [
|
||||
{ "name": "Anna's Archive", "url": "https://annas-archive.org/", "desc": "Bibliothèque shadow la plus complète du web. Livres, articles, thèses." },
|
||||
{ "name": "Sci-Hub", "url": "https://sci-hub.se/", "desc": "Accès libre aux articles scientifiques payants." },
|
||||
{ "name": "Library Genesis", "url": "https://libgen.is/", "desc": "Livres techniques et académiques en PDF." },
|
||||
{ "name": "Z-Library", "url": "https://z-lib.id/", "desc": "Bibliothèque numérique massive, interface soignée." },
|
||||
{ "name": "OpenLibrary (Internet Archive)", "url": "https://openlibrary.org/", "desc": "Prêt numérique gratuit, millions de livres." },
|
||||
{ "name": "Calibre", "url": "https://calibre-ebook.com/", "desc": "Gestion de bibliothèque numérique, convertisseur de formats." }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Dessin & Modélisation",
|
||||
"children": [
|
||||
{ "name": "FreeCAD", "url": "https://www.freecad.org/", "desc": "Modélisation 3D open source, paramétrique. Alternative à Rhino pour usage simple." },
|
||||
{ "name": "Blender", "url": "https://www.blender.org/", "desc": "3D, rendu, animation. La référence open source." },
|
||||
{ "name": "Inkscape", "url": "https://inkscape.org/", "desc": "Dessin vectoriel. Alternative à Illustrator." },
|
||||
{ "name": "GIMP", "url": "https://www.gimp.org/", "desc": "Retouche photo. Alternative à Photoshop." },
|
||||
{ "name": "Krita", "url": "https://krita.org/", "desc": "Dessin digital et croquis. Excellent pour les concepts." },
|
||||
{ "name": "LibreOffice Draw", "url": "https://www.libreoffice.org/", "desc": "Diagrammes, plans rapides, sans suite Adobe." }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Productivité & Texte",
|
||||
"children": [
|
||||
{ "name": "Obsidian", "url": "https://obsidian.md/", "desc": "PKM / prise de notes en Markdown. Gratuit pour usage personnel." },
|
||||
{ "name": "Logseq", "url": "https://logseq.com/", "desc": "PKM open source, graphe de connaissances." },
|
||||
{ "name": "Zotero", "url": "https://www.zotero.org/", "desc": "Gestionnaire de références bibliographiques. Indispensable pour la recherche." },
|
||||
{ "name": "Marktext", "url": "https://github.com/marktext/marktext", "desc": "Éditeur Markdown WYSIWYG, open source." },
|
||||
{ "name": "Typst", "url": "https://typst.app/", "desc": "Alternative moderne à LaTeX pour la mise en page de documents." },
|
||||
{ "name": "Pandoc", "url": "https://pandoc.org/", "desc": "Conversion universelle entre formats de documents (MD, DOCX, PDF, HTML...)." }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Dev & Infrastructure",
|
||||
"children": [
|
||||
{ "name": "VS Code", "url": "https://code.visualstudio.com/", "desc": "Éditeur de code. La référence, gratuit." },
|
||||
{ "name": "Coolify", "url": "https://coolify.io/", "desc": "Self-hosting simplifié. Alternative à Heroku/Vercel sur son propre VPS." },
|
||||
{ "name": "Hetzner Cloud", "url": "https://www.hetzner.com/cloud/", "desc": "VPS européen, tarifs très bas, data centers Allemagne." },
|
||||
{ "name": "Caddy", "url": "https://caddyserver.com/", "desc": "Serveur web avec HTTPS automatique. Plus simple que Nginx." },
|
||||
{ "name": "n8n", "url": "https://n8n.io/", "desc": "Automatisation open source (comme Zapier mais self-hostable)." },
|
||||
{ "name": "Gitea", "url": "https://gitea.io/", "desc": "Hébergement Git self-hosted. Alternative à GitHub." }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Vie privée & Sécurité",
|
||||
"children": [
|
||||
{ "name": "Bitwarden", "url": "https://bitwarden.com/", "desc": "Gestionnaire de mots de passe open source. Self-hostable." },
|
||||
{ "name": "ProtonMail", "url": "https://proton.me/mail", "desc": "Email chiffré, hébergement Suisse." },
|
||||
{ "name": "Signal", "url": "https://signal.org/", "desc": "Messagerie chiffrée E2E. La référence." },
|
||||
{ "name": "uBlock Origin", "url": "https://ublockorigin.com/", "desc": "Bloqueur de publicités et trackers, le plus efficace." },
|
||||
{ "name": "Mullvad VPN", "url": "https://mullvad.net/", "desc": "VPN respectueux de la vie privée, sans compte email requis." },
|
||||
{ "name": "Privacy Guides", "url": "https://www.privacyguides.org/", "desc": "Recommandations d'outils respectueux de la vie privée, par thème." }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Formation & Apprentissage",
|
||||
"children": [
|
||||
{ "name": "MIT OpenCourseWare", "url": "https://ocw.mit.edu/", "desc": "Cours du MIT en libre accès, toutes disciplines." },
|
||||
{ "name": "Khan Academy", "url": "https://www.khanacademy.org/", "desc": "Maths, sciences, programmation — gratuit, pédagogie excellente." },
|
||||
{ "name": "YouTube (canaux techniques)", "url": "https://www.youtube.com/", "desc": "Channals : The Coding Train, Fireship, 3Blue1Brown, etc." },
|
||||
{ "name": "freeCodeCamp", "url": "https://www.freecodecamp.org/", "desc": "Apprendre le développement web de zéro, gratuit et certifiant." },
|
||||
{ "name": "Coursera (audit gratuit)", "url": "https://www.coursera.org/", "desc": "Cours universitaires, audit gratuit disponible sur la plupart." }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Médias & Audio",
|
||||
"children": [
|
||||
{ "name": "Audacity", "url": "https://www.audacityteam.org/", "desc": "Enregistrement et édition audio. Référence open source." },
|
||||
{ "name": "yt-dlp", "url": "https://github.com/yt-dlp/yt-dlp", "desc": "Télécharger des vidéos/audio depuis YouTube et 1000+ sites." },
|
||||
{ "name": "VLC", "url": "https://www.videolan.org/vlc/", "desc": "Lecteur multimédia universel." },
|
||||
{ "name": "Kdenlive", "url": "https://kdenlive.org/", "desc": "Montage vidéo open source, non linéaire." },
|
||||
{ "name": "OBS Studio", "url": "https://obsproject.com/", "desc": "Enregistrement et streaming vidéo. La référence gratuite." }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Divers Utiles",
|
||||
"children": [
|
||||
{ "name": "Nextcloud", "url": "https://nextcloud.com/", "desc": "Cloud personnel self-hosted. Alternative à Google Drive/Dropbox." },
|
||||
{ "name": "Joplin", "url": "https://joplinapp.org/", "desc": "Notes chiffrées, sync Nextcloud, open source." },
|
||||
{ "name": "draw.io / diagrams.net", "url": "https://app.diagrams.net/", "desc": "Diagrammes et schémas, gratuit, pas de compte requis." },
|
||||
{ "name": "Excalidraw", "url": "https://excalidraw.com/", "desc": "Tableau blanc collaboratif, style hand-drawn, open source." },
|
||||
{ "name": "Fmhy.net (complet)", "url": "https://fmhy.net/", "desc": "L'arbre complet. Des milliers de ressources organisées par thème." }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
90
public/data/outils.json
Normal file
90
public/data/outils.json
Normal file
@@ -0,0 +1,90 @@
|
||||
{
|
||||
"simulateurs": [
|
||||
{
|
||||
"id": "autonomie",
|
||||
"icon": "🟢",
|
||||
"titre": "Simulateur Autonomie",
|
||||
"url": "https://calculs.trans-former.fr/autonomie/",
|
||||
"description": "Évaluer le degré d'autonomie d'une famille à un site donné selon les ressources locales (eau, énergie, alimentation).",
|
||||
"cta": "Lancer le simulateur →",
|
||||
"tag": "outil-aep"
|
||||
}
|
||||
],
|
||||
"simulateurs_inspirations": [
|
||||
{
|
||||
"id": "florquin-prix-m2",
|
||||
"icon": "💡",
|
||||
"titre": "Estimation prix au m² — Florquin Studio",
|
||||
"url": "https://offre.florquinstudio.com/",
|
||||
"description": "Une agence parisienne qui a construit un système d'estimation au m² assez fin. Pas dans nos outils, mais inspirant pour qui veut industrialiser son chiffrage.",
|
||||
"tag": "inspiration-externe"
|
||||
}
|
||||
],
|
||||
"opensource": [
|
||||
{
|
||||
"id": "dictee-universelle-groq",
|
||||
"icon": "🎤",
|
||||
"titre": "Dictée universelle Groq",
|
||||
"url": "https://github.com/Jayjay-nene/dictee-universelle-groq",
|
||||
"description": "Appuie sur une touche, parle, le texte apparaît au curseur avec ponctuation et majuscules auto. Transcription Whisper Groq < 1s. Marche dans toutes les applis Windows. Outil par Jayjay-nene.",
|
||||
"tag": "recommande"
|
||||
},
|
||||
{
|
||||
"id": "atis-voice",
|
||||
"icon": "🎙",
|
||||
"titre": "Atis Voice (text-to-speech)",
|
||||
"url": null,
|
||||
"description": "Pipeline TTS pour transformer un texte en audio.",
|
||||
"tag": "disponible"
|
||||
},
|
||||
{
|
||||
"id": "install-vps",
|
||||
"icon": "🖥",
|
||||
"titre": "Install VPS open source",
|
||||
"url": null,
|
||||
"description": "Setup pas-à-pas pour monter son propre VPS (Hetzner, Coolify, Caddy, Postgres…) en mode reproductible.",
|
||||
"tag": "a-venir"
|
||||
},
|
||||
{
|
||||
"id": "skills-claude-code",
|
||||
"icon": "⚙",
|
||||
"titre": "Skills Claude Code",
|
||||
"url": null,
|
||||
"description": "Skills custom pour booster sa pratique avec un agent IA.",
|
||||
"tag": "a-venir"
|
||||
}
|
||||
],
|
||||
"bifurcation": {
|
||||
"intro": "Beaucoup de jeunes diplômés en archi cherchent des chemins alternatifs. Cette section rassemble des témoignages, expériences, et ressources sur ce que c'est que de bifurquer.",
|
||||
"videos_ofqa": [
|
||||
{ "ep": "01", "titre": "Architectes indépendants", "personnes": "Jules Nény & Imane Fatmi", "url": "https://youtu.be/aMreB5KdNhY" },
|
||||
{ "ep": "02", "titre": "Artiste & Maçon", "personnes": "Romane Dutour & Maël Canal", "url": "https://youtu.be/9gpjokx2ndI" },
|
||||
{ "ep": "03", "titre": "Social & BTP", "personnes": "Célia Berdy & Esilda Perrot", "url": null, "note": "vidéo perdue, doc PDF seulement" },
|
||||
{ "ep": "04", "titre": "Menuisier & Paysagiste", "personnes": "Adel Mohamedi & Julie Bowie", "url": "https://youtu.be/yKaRQhA3Z6g" },
|
||||
{ "ep": "05", "titre": "Éco-construction", "personnes": "Edouard Vermès", "url": "https://youtu.be/97bDg1BjeuQ" },
|
||||
{ "ep": "06", "titre": "Musicien & Urbaniste", "personnes": "Ruben Madar & Antoine Troccaz", "url": "https://drive.google.com/drive/folders/14g8YBn5bZAy8aIkHzQlOTrTtnWOaqRO3" },
|
||||
{ "ep": "07", "titre": "AMO & Réemploi", "personnes": "Domitille Chaigne & Clémence Bondon", "url": "https://drive.google.com/file/d/1Q9Za81CElszmMn5n8dBsG0pJkiWmB32c/view" },
|
||||
{ "ep": "08", "titre": "Gouvernance école", "personnes": "Solenn Guével", "url": "https://drive.google.com/drive/folders/1UaLsSyQcJydkXyV71klrY1tv9KgAh-mG" },
|
||||
{ "ep": "bonus", "titre": "PFE — invitation à faire collectif", "personnes": "Jules Nény & Imane Fatmi", "url": "https://youtu.be/4qTEIC2Lmqw" }
|
||||
],
|
||||
"coalition_ensa_pb": {
|
||||
"titre": "Coalition inter-asso ENSA-PB — victoire salle des enseignants",
|
||||
"description": "Une coalition d'associations étudiantes a obtenu l'usage de la salle des enseignants pour des temps de travail collectif."
|
||||
},
|
||||
"ressources_externes": [
|
||||
{
|
||||
"id": "drop-the-kutch",
|
||||
"icon": "🎧",
|
||||
"titre": "Podcast Drop the Kutch — Sâm Afchar",
|
||||
"url": "https://podcasts-francais.fr/podcast/drop-the-kutsch",
|
||||
"description": "Témoignages de bifurcations post études d'archi. Super taf."
|
||||
}
|
||||
]
|
||||
},
|
||||
"section_5_placeholder": {
|
||||
"titre": "[V2 — Login] Logiciels pro",
|
||||
"description": "Logiciels lourds (Adobe, AutoCAD…) — accès mutualisé. Disponible après création de compte AEP.",
|
||||
"status": "bientot-login"
|
||||
},
|
||||
"footer_contribution": "Tu utilises un outil qui mérite d'être ici ? Écris-moi : contact@trans-former.fr"
|
||||
}
|
||||
70
public/data/pfe-engages.json
Normal file
70
public/data/pfe-engages.json
Normal file
@@ -0,0 +1,70 @@
|
||||
{
|
||||
"projets": [
|
||||
{
|
||||
"id": "quartier-2030",
|
||||
"titre": "Votre quartier en 2030",
|
||||
"auteurs": ["Inconnu"],
|
||||
"annee": "2020",
|
||||
"ecole": "Inconnu",
|
||||
"url": "https://quartier-2030.firebaseapp.com/",
|
||||
"description": "Exploration prospective confrontant smart city, no future, résilience et deep ecology à l'échelle du quartier. Le travail donne à voir plusieurs futurs urbains contrastés, de l'utopie technologique au retrait radical, en laissant le visiteur naviguer entre les scénarios. Un travail d'orfèvre pour sortir de la pensée linéaire sur la ville.",
|
||||
"thumb": null,
|
||||
"link_status": "ok"
|
||||
},
|
||||
{
|
||||
"id": "seine-nature",
|
||||
"titre": "Seine — nature urbaine",
|
||||
"auteurs": ["Inconnu"],
|
||||
"annee": "2019",
|
||||
"ecole": "Inconnu",
|
||||
"url": "http://www.seine.natureurbaine.com/00_index/page_theme/theme.html",
|
||||
"description": "Projet de transformation territoriale collective autour de la Seine, pensé comme une démarche systémique et pluridisciplinaire. L'intervention se concentre sur les marges périurbaines, traitées par une logique d'acupuncture : des micro-interventions précises pour enclencher des dynamiques plus larges. L'approche refuse le grand projet unique au profit d'un réseau de petites transformations.",
|
||||
"thumb": null,
|
||||
"link_status": "ok"
|
||||
},
|
||||
{
|
||||
"id": "tmip",
|
||||
"titre": "TMIP — Transformation de la Maison Individuelle Périurbaine",
|
||||
"auteurs": ["Jules Nény"],
|
||||
"annee": "2019",
|
||||
"ecole": "ENSA Paris-Belleville",
|
||||
"url": "https://issuu.com/transformationresilientes/docs/tmip_archijeunes_cstb_",
|
||||
"description": "Étude de la maison périurbaine sous l'angle des Gilets jaunes : comment ce lieu de vie concentre les tensions entre émancipation individuelle et dépendance structurelle (voiture, énergie, services). Le projet propose un réseau de micro-infrastructures partagées pour transformer ces maisons isolées en systèmes résilients interconnectés. Publié avec ARCHI'JEUNES et le CSTB.",
|
||||
"thumb": null,
|
||||
"link_status": "ok"
|
||||
},
|
||||
{
|
||||
"id": "filiere-bois",
|
||||
"titre": "Enquête sur les paysages forestiers franciliens",
|
||||
"auteurs": ["Quid Architecture"],
|
||||
"annee": "2021",
|
||||
"ecole": "Inconnu",
|
||||
"url": "https://www.faireparis.com/fr/projets/faire-2021/enquete-sur-les-paysages-forestiers-franciliens-2159.html",
|
||||
"description": "Projet lauréat FAIRE 2021. Enquête sur les dysfonctionnements de la filière bois en Île-de-France, aux interfaces entre sylviculteurs, scieries, artisans et maîtres d'ouvrage. Le travail cartographie les ruptures de filière et propose des interventions concrètes pour réparer les liens entre forêt et construction. Une démarche systémique rare dans les études architecturales.",
|
||||
"thumb": null,
|
||||
"link_status": "ok"
|
||||
},
|
||||
{
|
||||
"id": "jeu-champagne",
|
||||
"titre": "Jeu de rôle Champagne PFE — Plateau",
|
||||
"auteurs": ["Inconnu"],
|
||||
"annee": "2020",
|
||||
"ecole": "Inconnu",
|
||||
"url": "https://campfe2020.wixsite.com/champagnepfe/plateau",
|
||||
"description": "Dispositif ludique et coopératif développé comme outil de médiation entre acteurs d'un territoire. Le jeu de rôle permet de traverser des problèmes complexes en engageant simultanément des parties prenantes aux intérêts divergents. Une exploration de l'architecture comme processus collectif plutôt que comme objet produit.",
|
||||
"thumb": null,
|
||||
"link_status": "ok"
|
||||
},
|
||||
{
|
||||
"id": "transition-agricole",
|
||||
"titre": "Transition agricole — réinvestissement de fermes traditionnelles",
|
||||
"auteurs": ["Inconnu"],
|
||||
"annee": "2020",
|
||||
"ecole": "Inconnu",
|
||||
"url": "https://www.calameo.com/books/007306483e0b23edb1db7",
|
||||
"description": "Projet sur la transformation de fermes traditionnelles dans une logique agricole moderne et diversifiée. L'étude explore comment l'architecture peut accompagner les transitions d'usage des bâtiments ruraux, en articulant patrimonial et fonctionnel. Voir aussi le projet complémentaire sur la Seine aval : https://www.calameo.com/books/007063623f4d4b800b01d",
|
||||
"thumb": null,
|
||||
"link_status": "ok"
|
||||
}
|
||||
]
|
||||
}
|
||||
12
public/icons/CREDITS.md
Normal file
12
public/icons/CREDITS.md
Normal file
@@ -0,0 +1,12 @@
|
||||
# Icons Credits
|
||||
|
||||
## outils-wrench.svg
|
||||
|
||||
**Source :** Heroicons — `wrench-screwdriver` icon
|
||||
**Licence :** MIT
|
||||
**URL :** https://heroicons.com/
|
||||
**Note :** Fallback Heroicons utilisé car l'API Noun Project a retourné 403 Forbidden au moment du build (2026-05-22). L'icône `wrench-screwdriver` de Heroicons (MIT) est une clé à molette + tournevis, style line art minimaliste, compatible avec l'identité visuelle AEP.
|
||||
|
||||
Si tu veux substituer par une icône Noun Project, utilise l'endpoint :
|
||||
`GET https://api.thenounproject.com/v2/icon?query=wrench&limit=20`
|
||||
avec les credentials OAuth 1.0a stockées dans `_System/API-credentials.md`.
|
||||
3
public/icons/outils-wrench.svg
Normal file
3
public/icons/outils-wrench.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M11.42 15.17 17.25 21A2.652 2.652 0 0 0 21 17.25l-5.877-5.877M11.42 15.17l2.496-3.03c.317-.384.74-.626 1.208-.766M11.42 15.17l-4.655 5.653a2.548 2.548 0 1 1-3.586-3.586l5.654-4.654m5.598-2.337 3.07-3.293a2.25 2.25 0 0 0-3.182-3.182l-3.293 3.07M6.75 12.75l-2.25.75.75-2.25 2.25-.75-.75 2.25Z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 493 B |
@@ -1,119 +0,0 @@
|
||||
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(),
|
||||
}
|
||||
})
|
||||
10
server/routes/data/auteurs-pensees.json.get.ts
Normal file
10
server/routes/data/auteurs-pensees.json.get.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
export default defineEventHandler((event) => {
|
||||
const path = join(process.cwd(), 'public', 'data', 'auteurs-pensees.json')
|
||||
const raw = readFileSync(path, 'utf-8')
|
||||
setResponseHeader(event, 'content-type', 'application/json; charset=utf-8')
|
||||
setResponseHeader(event, 'cache-control', 'public, max-age=300')
|
||||
return raw
|
||||
})
|
||||
Reference in New Issue
Block a user