2 Commits

Author SHA1 Message Date
Jules Neny
c6295ea228 fix: chatbot corpus onMounted + CSS auteurs lisibilite + remove /rag placeholder
- ChatbotPensees: deplace lecture localStorage dans onMounted (fix bug hydratation SSR/CSR, corpus 'both' garanti au render initial)
- CartePensees: opacity 1, stroke-width 2px, font-weight 600 (auteurs lisibles sur fond pastel)
- pages/rag.vue: supprime la page placeholder /rag (route disparait, Nuxt retourne 404)
2026-05-12 01:00:03 +02:00
Jules Neny
cd2d225e91 feat(media): split layout 2/3 carte + 1/3 chatbot + toggle plein ecran
- Chatbot passe d'overlay flottant a inline (1/3 hauteur permanent)
- Bouton [Carte plein ecran] / [Chatbot plein ecran] / [Vue partagee]
- Transition CSS douce 0.3s ease sur height/flex-basis/opacity
- Restart D3 simulation alpha(0.3) apres transition (350ms delay)
- localStorage persistance du mode (cle media-layout-mode)
- Responsive mobile <768px : stack vertical carte 60vh + chatbot 40vh
- CartePensees expose triggerResize() via defineExpose
- ChatbotPensees : prop inline booleen, 2 modes rendu (overlay/inline)

V2 Phase 4

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 00:06:51 +02:00
4 changed files with 469 additions and 172 deletions

View File

@@ -128,8 +128,18 @@ watch(() => props.active, (val) => { if (val && import.meta.client && props.data
watch(() => props.data, (val) => { if (val && props.active && import.meta.client) 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() } }) onMounted(async () => { if (import.meta.client && props.data && props.active) { await nextTick(); initGraph() } })
onUnmounted(() => { if (simulation) simulation.stop() }) onUnmounted(() => { if (simulation) simulation.stop() })
// Expose pour reset D3 apres resize du conteneur
function triggerResize() {
if (simulation) {
simulation.alpha(0.3).restart()
} else if (import.meta.client && props.data && props.active) {
initGraph()
}
}
defineExpose({ triggerResize })
</script> </script>
<style> <style>
.pensees-auteur-label { fill: var(--nav-text); opacity: 0.75; paint-order: stroke; stroke: var(--nav-bg); stroke-width: 3px; stroke-linejoin: round; user-select: none; } .pensees-auteur-label { fill: var(--nav-text); opacity: 1; paint-order: stroke; stroke: var(--nav-bg); stroke-width: 2px; stroke-linejoin: round; user-select: none; font-weight: 600; }
</style> </style>

View File

@@ -1,4 +1,6 @@
<template> <template>
<!-- Mode overlay : bouton flottant bottom-right (legacy) -->
<template v-if="!inline">
<button v-if="!open" @click="open = true" <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" 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;" style="height:48px;background:var(--nav-primary);color:var(--nav-text-on-primary);font-size:0.875rem;font-weight:600;"
@@ -13,7 +15,8 @@
<div v-if="open" class="fixed bottom-6 right-6 z-[1000] flex flex-col" <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);" 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"> role="dialog" aria-modal="true" aria-label="RAG Pensees Ecologiques">
<!-- Header -->
<!-- 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 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> <div>
<p class="text-sm font-bold" style="color:var(--nav-text);">RAG Pensees Ecologiques</p> <p class="text-sm font-bold" style="color:var(--nav-text);">RAG Pensees Ecologiques</p>
@@ -27,26 +30,98 @@
</button> </button>
</div> </div>
<!-- Corpus toggle --> <!-- 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="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"> <div class="flex gap-1" role="group" aria-label="Choisir le corpus">
<button <button v-for="opt in corpusOptions" :key="opt.value" @click="setCorpus(opt.value)" :title="opt.tooltip"
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" class="flex-1 px-2 py-1 rounded text-xs font-medium transition-colors"
:style="corpus === opt.value :style="corpus === opt.value ? 'background:var(--nav-primary);color:var(--nav-text-on-primary);' : 'background:var(--nav-bg-alt);color:var(--nav-text-muted);'"
? 'background:var(--nav-primary);color:var(--nav-text-on-primary);' :aria-pressed="corpus === opt.value">{{ opt.label }}</button>
: 'background:var(--nav-bg-alt);color:var(--nav-text-muted);'" </div>
:aria-pressed="corpus === opt.value"> </div>
{{ opt.label }}
<!-- 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> </button>
</div> </div>
</div> </div>
<!-- Messages --> </div>
<div ref="msgEl" class="flex-1 overflow-y-auto px-4 py-3 flex flex-col gap-3" style="min-height:0;"> </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;"> <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-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-if="corpus === 'projets'">Pose une question sur les projets d'architecture de Jules : Butte Pinson, strategie thermique, partis pris constructifs...</template>
@@ -79,10 +154,10 @@
<div v-if="err" class="text-xs px-3 py-2 rounded-xl" style="background:#fee;color:#c0392b;">{{ err }}</div> <div v-if="err" class="text-xs px-3 py-2 rounded-xl" style="background:#fee;color:#c0392b;">{{ err }}</div>
</div> </div>
<!-- Input --> <!-- Input inline -->
<div class="shrink-0 px-3 py-3" style="border-top:1px solid var(--nav-bg-alt);"> <div class="shrink-0 px-3 py-3" style="border-top:1px solid var(--nav-bg-alt);">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<input ref="inputEl" v-model="q" type="text" placeholder="Ta question..." maxlength="500" <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" 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);" style="background:var(--nav-bg);color:var(--nav-text);border:1px solid var(--nav-bg-alt);"
@keydown.enter.prevent="send" /> @keydown.enter.prevent="send" />
@@ -96,8 +171,8 @@
</button> </button>
</div> </div>
</div> </div>
</div> </div>
</Transition>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@@ -107,8 +182,6 @@ type CorpusMode = 'pensees' | 'projets' | 'both'
const CORPUS_STORAGE_KEY = 'chatbot-pensees-corpus' const CORPUS_STORAGE_KEY = 'chatbot-pensees-corpus'
// Patterns projet : les sources qui matchent sont des refs projet archi
// Pattern actuel : butte pinson. Extensible en ajoutant d'autres slugs.
const PROJECT_SOURCE_PATTERNS = [/butte.?pinson/i, /butte_pinson/i] const PROJECT_SOURCE_PATTERNS = [/butte.?pinson/i, /butte_pinson/i]
function isProjectSource(s: string): boolean { function isProjectSource(s: string): boolean {
@@ -121,43 +194,46 @@ const corpusOptions: { value: CorpusMode; label: string; tooltip: string }[] = [
{ value: 'both', label: 'Croise*', tooltip: 'Projets ancres + pensees en eclairage (defaut)' }, { value: 'both', label: 'Croise*', tooltip: 'Projets ancres + pensees en eclairage (defaut)' },
] ]
const props = defineProps<{ auteurContext?: string | null }>() const props = defineProps<{
auteurContext?: string | null
inline?: boolean
}>()
const open = ref(false) const open = ref(false)
const q = ref('') const q = ref('')
const messages = ref<Message[]>([]) const messages = ref<Message[]>([])
const loading = ref(false) const loading = ref(false)
const err = ref('') const err = ref('')
const toggled = ref<Record<number, boolean>>({}) const toggled = ref<Record<number, boolean>>({})
const msgEl = ref<HTMLElement | null>(null) const msgElOverlay = ref<HTMLElement | null>(null)
const inputEl = ref<HTMLInputElement | null>(null) const msgElInline = ref<HTMLElement | null>(null)
const inputElOverlay = ref<HTMLInputElement | null>(null)
const inputElInline = ref<HTMLInputElement | null>(null)
const corpusCount = 18 const corpusCount = 18
// Corpus state - init depuis localStorage
const corpus = ref<CorpusMode>('both') const corpus = ref<CorpusMode>('both')
if (typeof window !== 'undefined') { onMounted(() => {
const saved = window.localStorage.getItem(CORPUS_STORAGE_KEY) as CorpusMode | null const saved = window.localStorage.getItem(CORPUS_STORAGE_KEY) as CorpusMode | null
if (saved && ['pensees', 'projets', 'both'].includes(saved)) { if (saved && ['pensees', 'projets', 'both'].includes(saved)) {
corpus.value = saved corpus.value = saved
} }
} })
function setCorpus(val: CorpusMode) { function setCorpus(val: CorpusMode) {
corpus.value = val corpus.value = val
if (typeof window !== 'undefined') {
window.localStorage.setItem(CORPUS_STORAGE_KEY, val) window.localStorage.setItem(CORPUS_STORAGE_KEY, val)
} }
}
watch(open, (val) => { watch(open, (val) => {
if (!val) return if (!val) return
nextTick(() => inputEl.value?.focus()) nextTick(() => inputElOverlay.value?.focus())
if (props.auteurContext && messages.value.length === 0) if (props.auteurContext && messages.value.length === 0)
q.value = `Quelles sont les theses centrales de ${props.auteurContext} ?` q.value = `Quelles sont les theses centrales de ${props.auteurContext} ?`
}) })
watch(() => props.auteurContext, (ctx) => { watch(() => props.auteurContext, (ctx) => {
if (!ctx) return if (!ctx) return
if (!open.value) open.value = true if (!props.inline && !open.value) open.value = true
if (messages.value.length === 0) q.value = `Quelles sont les theses centrales de ${ctx} ?` if (messages.value.length === 0) q.value = `Quelles sont les theses centrales de ${ctx} ?`
}) })
@@ -168,7 +244,8 @@ async function send() {
messages.value.push({ role: 'user', content: query }) messages.value.push({ role: 'user', content: query })
q.value = '' q.value = ''
loading.value = true loading.value = true
await nextTick(); scrollBottom() await nextTick()
scrollBottom()
try { try {
const res = await $fetch<{ response: string }>('/api/chatbot-pensees', { const res = await $fetch<{ response: string }>('/api/chatbot-pensees', {
method: 'POST', method: 'POST',
@@ -180,11 +257,15 @@ async function send() {
err.value = s === 429 ? 'Limite atteinte.' : s === 503 ? 'RAG indisponible.' : 'Erreur - reessaie.' err.value = s === 429 ? 'Limite atteinte.' : s === 503 ? 'RAG indisponible.' : 'Erreur - reessaie.'
} finally { } finally {
loading.value = false loading.value = false
await nextTick(); scrollBottom() await nextTick()
scrollBottom()
} }
} }
function scrollBottom() { if (msgEl.value) msgEl.value.scrollTop = msgEl.value.scrollHeight } function scrollBottom() {
const el = props.inline ? msgElInline.value : msgElOverlay.value
if (el) el.scrollTop = el.scrollHeight
}
function renderMd(t: string) { 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>' return '<p>' + t.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>').replace(/\*(.+?)\*/g, '<em>$1</em>').replace(/\n\n/g, '</p><p>').replace(/\n/g, '<br>') + '</p>'
@@ -197,12 +278,10 @@ function parseSrc(t: string): string[] {
return [...new Set([...t.matchAll(/\[([^\]]{5,80})\]/g)].filter(m => m[1].includes(' - ')).map(m => m[1]))] return [...new Set([...t.matchAll(/\[([^\]]{5,80})\]/g)].filter(m => m[1].includes(' - ')).map(m => m[1]))]
} }
// Filtrage UI des sources selon corpus actif
function filteredSources(t: string): string[] { function filteredSources(t: string): string[] {
const all = parseSrc(t) const all = parseSrc(t)
if (corpus.value === 'both') return all if (corpus.value === 'both') return all
if (corpus.value === 'projets') return all.filter(s => isProjectSource(s)) if (corpus.value === 'projets') return all.filter(s => isProjectSource(s))
// corpus === 'pensees' : exclure les sources projet
return all.filter(s => !isProjectSource(s)) return all.filter(s => !isProjectSource(s))
} }
</script> </script>

View File

@@ -1,22 +1,33 @@
<template> <template>
<div class="flex h-full overflow-hidden" style="background: var(--nav-bg);"> <div class="media-page" style="background: var(--nav-bg);">
<!-- ZONE PRINCIPALE (pleine largeur, pas de sidebar) --> <!-- ZONE PRINCIPALE (pleine largeur, pas de sidebar) -->
<main class="flex-1 flex flex-col overflow-hidden relative"> <main class="media-main">
<!-- Header onglet --> <!-- Header onglet -->
<div class="shrink-0 px-5 py-3" <div class="shrink-0 px-5 py-3"
style="background: var(--nav-surface); border-bottom: 1px solid var(--nav-bg-alt);"> style="background: var(--nav-surface); border-bottom: 1px solid var(--nav-bg-alt);">
<h1 class="font-bold text-base" style="color: var(--nav-text);">ATIS Média</h1> <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);"> <p class="text-xs mt-0.5" style="color: var(--nav-text-muted);">
{{ corpusCount }} auteurs ingérés dans le RAG - carte FRACAS Bonpote V2 {{ corpusCount }} auteurs ingeres dans le RAG - carte FRACAS Bonpote V2
</p> </p>
</div> </div>
<!-- Carte pensees (D3 force-directed) --> <!-- Conteneur split / plein ecran -->
<div class="flex-1 overflow-hidden relative"> <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> <ClientOnly>
<CartePensees <CartePensees
ref="cartePenseesRef"
:data="penseesData" :data="penseesData"
:active="true" :active="true"
@select-auteur="onSelectAuteur" @select-auteur="onSelectAuteur"
@@ -29,6 +40,59 @@
</ClientOnly> </ClientOnly>
</div> </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> </main>
<!-- Fiche auteur modal --> <!-- Fiche auteur modal -->
@@ -40,9 +104,6 @@
@interroger-rag="onInterrogerRag" @interroger-rag="onInterrogerRag"
/> />
<!-- Chatbot flottant -->
<ChatbotPensees :auteurContext="chatbotAuteur" />
</div> </div>
</template> </template>
@@ -52,14 +113,27 @@ 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; livres_rag: LivreRag[]; theses_cles: string[]; bio_courte: string }
interface PenseesData { meta: any; ecoles: EcoleData[]; auteurs: AuteurData[] } interface PenseesData { meta: any; ecoles: EcoleData[]; auteurs: AuteurData[] }
type LayoutMode = 'split' | 'carte-full' | 'chatbot-full'
const STORAGE_KEY = 'media-layout-mode'
const ficheOpen = ref(false) const ficheOpen = ref(false)
const ficheAuteurId = ref<string | null>(null) const ficheAuteurId = ref<string | null>(null)
const chatbotAuteur = ref<string | null>(null) const chatbotAuteur = ref<string | null>(null)
const penseesData = ref<PenseesData | 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) const corpusCount = computed(() => penseesData.value?.auteurs.length ?? 0)
onMounted(async () => { 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 { try {
penseesData.value = await $fetch<PenseesData>('/data/auteurs-pensees.json') penseesData.value = await $fetch<PenseesData>('/data/auteurs-pensees.json')
} catch (e) { } catch (e) {
@@ -67,6 +141,26 @@ onMounted(async () => {
} }
}) })
// 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) { function onSelectAuteur(id: string) {
ficheAuteurId.value = id ficheAuteurId.value = id
ficheOpen.value = true ficheOpen.value = true
@@ -77,7 +171,159 @@ function onInterrogerRag(auteurId: string) {
ficheOpen.value = false ficheOpen.value = false
const auteur = penseesData.value?.auteurs.find(a => a.id === auteurId) const auteur = penseesData.value?.auteurs.find(a => a.id === auteurId)
chatbotAuteur.value = auteur?.nom ?? null chatbotAuteur.value = auteur?.nom ?? null
// Basculer en split pour que le chatbot soit visible
if (layoutMode.value === 'carte-full') {
setLayoutMode('split')
}
} }
useHead({ title: 'AEP - Média - Carte FRACAS Bonpote' }) useHead({ title: 'AEP - Media - Carte FRACAS Bonpote' })
</script> </script>
<style scoped>
/* Page container : flex column, prend toute la hauteur viewport */
.media-page {
display: flex;
height: 100%;
overflow: hidden;
}
.media-main {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
position: relative;
}
/* Conteneur des slots carte + toggle + chatbot */
.layout-container {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
min-height: 0;
}
/* --- Slot carte --- */
.carte-slot {
overflow: hidden;
position: relative;
transition: flex-basis 0.3s ease, height 0.3s ease, opacity 0.2s ease;
}
.carte-split {
flex: 2 1 0;
min-height: 0;
opacity: 1;
}
.carte-full {
flex: 1 1 100%;
min-height: 0;
opacity: 1;
}
.carte-hidden {
flex: 0 0 0;
height: 0;
opacity: 0;
overflow: hidden;
}
/* --- Barre de toggle --- */
.layout-toggle-bar {
flex-shrink: 0;
display: flex;
align-items: center;
gap: 6px;
padding: 4px 12px;
background: var(--nav-bg);
border-top: 1px solid var(--nav-bg-alt);
border-bottom: 1px solid var(--nav-bg-alt);
min-height: 38px;
}
.toggle-btn {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 4px 10px;
border-radius: 6px;
font-size: 0.75rem;
font-weight: 500;
cursor: pointer;
background: var(--nav-bg-alt);
color: var(--nav-text-muted);
border: 1px solid transparent;
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.toggle-btn:hover {
background: var(--nav-surface);
color: var(--nav-text);
}
.toggle-btn.active {
background: var(--nav-primary);
color: var(--nav-text-on-primary);
border-color: var(--nav-primary);
}
.toggle-btn-reset {
margin-left: auto;
background: var(--nav-surface);
color: var(--nav-text);
}
.toggle-btn-reset:hover {
background: var(--nav-bg-alt);
}
/* --- Slot chatbot --- */
.chatbot-slot {
overflow: hidden;
position: relative;
transition: flex-basis 0.3s ease, height 0.3s ease, opacity 0.2s ease;
border-top: 1px solid var(--nav-bg-alt);
}
.chatbot-split {
flex: 1 1 0;
min-height: 0;
opacity: 1;
}
.chatbot-full-mode {
flex: 1 1 100%;
min-height: 0;
opacity: 1;
}
.chatbot-hidden {
flex: 0 0 0;
height: 0;
opacity: 0;
overflow: hidden;
}
/* --- Responsive mobile (<768px) --- */
/* Stack vertical : carte 60vh + chatbot 40vh en mode split */
@media (max-width: 767px) {
.carte-split {
flex: 0 0 60vh;
height: 60vh;
}
.chatbot-split {
flex: 0 0 calc(40vh - 38px);
height: calc(40vh - 38px);
}
.toggle-btn span,
.toggle-btn {
font-size: 0.7rem;
padding: 3px 7px;
}
}
</style>

View File

@@ -1,38 +0,0 @@
<template>
<div class="flex flex-col items-center justify-center h-full gap-6" style="background: var(--nav-bg);">
<div class="text-center max-w-md px-6">
<div
class="inline-flex items-center justify-center w-14 h-14 rounded-2xl mb-5"
style="background: var(--nav-bg-alt);"
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="color: var(--nav-text-muted);">
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/>
<polyline points="3.27 6.96 12 12.01 20.73 6.96"/>
<line x1="12" y1="22.08" x2="12" y2="12"/>
</svg>
</div>
<h1 class="text-2xl font-bold mb-3" style="color: var(--nav-text);">RAG Retrieval Augmented Generation</h1>
<p class="text-sm leading-relaxed mb-6" style="color: var(--nav-text-muted);">
Une base de connaissances interrogeable par IA textes, rapports, manifestes et ressources documentaires sur l'architecture d'écologie politique.
</p>
<p class="text-xs font-semibold uppercase tracking-widest mb-6" style="color: var(--nav-text-muted); opacity: 0.6;">
Bientôt disponible
</p>
<NuxtLink
to="/"
class="inline-flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-semibold transition-all hover:opacity-80"
style="background: var(--nav-primary); color: var(--nav-text-on-primary);"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" aria-hidden="true">
<line x1="19" y1="12" x2="5" y2="12"/>
<polyline points="12 19 5 12 12 5"/>
</svg>
Retour à l'écosystème
</NuxtLink>
</div>
</div>
</template>
<script setup lang="ts">
useHead({ title: 'RAG AEP (bientôt disponible)' })
</script>