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>
This commit is contained in:
@@ -128,6 +128,16 @@ 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>
|
||||||
|
|||||||
@@ -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,18 +194,23 @@ 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') {
|
if (typeof window !== 'undefined') {
|
||||||
@@ -151,13 +229,13 @@ function setCorpus(val: CorpusMode) {
|
|||||||
|
|
||||||
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 +246,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 +259,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 +280,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>
|
||||||
|
|||||||
266
pages/media.vue
266
pages/media.vue
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user