- Récupérés depuis commit vault b700612^ (état pré-chirurgie git) - FicheFamilleModal.vue (284L) — PV2-5g - FicheModalV2.vue (341L) + NavMapV2.vue (243L) — PV2-5 - HashtagFilter.vue (97L) + IntentionBanner.vue (76L) — PV2-5 - GraphView.vue (860L) — PV2-5b+5e+5f+5g complet - ChatbotPlaceholder.vue (423L) — version chatbot-v2 - pages/index.vue (517L) — carte unifiée 3 onglets - types/structure-v2.ts, assets/css/v2-bifurcation.css - server/api/chatbot-v2.post.ts, server/utils/vectorSearch.ts Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
342 lines
13 KiB
Vue
342 lines
13 KiB
Vue
<template>
|
|
<Teleport to="body">
|
|
<!-- Backdrop -->
|
|
<Transition name="backdrop">
|
|
<div
|
|
v-if="modelValue && structureId != null"
|
|
class="fixed inset-0 z-[1500]"
|
|
style="background: rgba(26,34,56,0.55);"
|
|
@click="close"
|
|
aria-hidden="true"
|
|
/>
|
|
</Transition>
|
|
|
|
<!-- Modal -->
|
|
<Transition name="modal">
|
|
<div
|
|
v-if="modelValue && structureId != null && structure"
|
|
class="fixed z-[1501] top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 flex flex-col"
|
|
style="
|
|
width: min(780px, 94vw);
|
|
max-height: 90vh;
|
|
background: var(--nav-bg);
|
|
border-radius: 16px;
|
|
box-shadow: 0 16px 64px rgba(26,34,56,0.28);
|
|
overflow: hidden;
|
|
"
|
|
role="dialog"
|
|
aria-modal="true"
|
|
:aria-label="structure?.nom ?? 'Fiche structure'"
|
|
@keydown.esc="close"
|
|
>
|
|
<!-- Header modal -->
|
|
<div
|
|
class="flex items-center justify-between px-5 py-3 shrink-0"
|
|
:style="`border-bottom: 3px solid ${familleColor}; background: var(--nav-surface);`"
|
|
>
|
|
<div class="flex items-center gap-3">
|
|
<!-- Pastille famille -->
|
|
<div
|
|
class="w-3 h-3 rounded-full shrink-0"
|
|
:style="`background: ${familleColor};`"
|
|
/>
|
|
<span class="text-sm font-semibold" style="color: var(--nav-text-muted);">
|
|
{{ familleLabel }}
|
|
</span>
|
|
<!-- Badges -->
|
|
<div class="flex gap-1.5">
|
|
<span
|
|
v-if="structure.badges.centre_ressources"
|
|
class="px-2 py-0.5 rounded-full text-xs font-medium"
|
|
style="background: #2d8a6b22; color: #2d8a6b;"
|
|
>Centre ressources</span>
|
|
<span
|
|
v-if="structure.badges.mouvement_manifeste"
|
|
class="px-2 py-0.5 rounded-full text-xs font-medium"
|
|
style="background: #c44a2f22; color: #c44a2f;"
|
|
>Manifeste</span>
|
|
<span
|
|
v-if="structure.badges.contre_pouvoir_spatial"
|
|
class="px-2 py-0.5 rounded-full text-xs font-medium"
|
|
style="background: #1a3a6b22; color: #1a3a6b;"
|
|
>Contre-pouvoir</span>
|
|
<span
|
|
v-if="structure.badges.f6_recherche_politique"
|
|
class="px-2 py-0.5 rounded-full text-xs font-medium"
|
|
style="background: #6b3fa022; color: #6b3fa0;"
|
|
>Recherche pol.</span>
|
|
</div>
|
|
</div>
|
|
<div class="flex items-center gap-2">
|
|
<a
|
|
v-if="structure.url"
|
|
:href="structure.url"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
class="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-opacity hover:opacity-70"
|
|
style="background: var(--nav-bg-alt); color: var(--nav-text);"
|
|
>
|
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
|
|
<polyline points="15 3 21 3 21 9"/>
|
|
<line x1="10" y1="14" x2="21" y2="3"/>
|
|
</svg>
|
|
Site web
|
|
</a>
|
|
<button
|
|
@click="close"
|
|
class="w-8 h-8 rounded-lg flex items-center justify-center transition-colors"
|
|
style="background: var(--nav-bg-alt); color: var(--nav-text-muted);"
|
|
aria-label="Fermer"
|
|
>
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" aria-hidden="true">
|
|
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Contenu scrollable -->
|
|
<div class="flex-1 overflow-y-auto px-5 py-5">
|
|
<!-- Nom + meta -->
|
|
<div class="mb-4">
|
|
<h2 class="text-xl font-bold mb-1" style="color: var(--nav-text);">{{ structure.nom }}</h2>
|
|
<div class="flex flex-wrap gap-2 text-sm" style="color: var(--nav-text-muted);">
|
|
<span>{{ structure.type_principal }}</span>
|
|
<span>·</span>
|
|
<span>{{ structure.ville }}, {{ structure.pays }}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Hashtags -->
|
|
<div v-if="structure.hashtags.length" class="flex flex-wrap gap-1.5 mb-4">
|
|
<span
|
|
v-for="tag in structure.hashtags"
|
|
:key="tag"
|
|
class="px-2 py-0.5 rounded-full text-xs"
|
|
style="background: var(--nav-bg-alt); color: var(--nav-text-muted);"
|
|
>{{ tag }}</span>
|
|
</div>
|
|
|
|
<!-- Description courte -->
|
|
<p class="text-sm leading-relaxed mb-4" style="color: var(--nav-text);">{{ structure.description_courte }}</p>
|
|
|
|
<!-- Description longue (expandable) -->
|
|
<div v-if="structure.description_longue" class="mb-4">
|
|
<div
|
|
class="text-sm leading-relaxed"
|
|
style="color: var(--nav-text); white-space: pre-wrap;"
|
|
:style="showFullDesc ? '' : 'max-height: 120px; overflow: hidden;'"
|
|
>{{ structure.description_longue }}</div>
|
|
<button
|
|
@click="showFullDesc = !showFullDesc"
|
|
class="mt-2 text-xs underline"
|
|
style="color: var(--nav-text-muted);"
|
|
>{{ showFullDesc ? 'Réduire' : 'Lire la suite…' }}</button>
|
|
</div>
|
|
|
|
<!-- Pensées rattachées -->
|
|
<div v-if="structure.pensees && structure.pensees.length" class="mb-4">
|
|
<h3 class="text-xs font-bold uppercase tracking-wide mb-2" style="color: var(--nav-text-muted);">Pensées rattachées</h3>
|
|
<div class="flex flex-wrap gap-1.5">
|
|
<span
|
|
v-for="pensee in structure.pensees"
|
|
:key="pensee.id"
|
|
class="px-2 py-0.5 rounded text-xs"
|
|
:style="pensee.confiance === 'ia_suggested'
|
|
? 'background: var(--nav-bg-alt); color: var(--nav-text-muted); border: 1px dashed var(--nav-bg-alt);'
|
|
: 'background: var(--nav-bg-alt); color: var(--nav-text);'"
|
|
:title="pensee.confiance === 'ia_suggested' ? 'IA suggéré' : ''"
|
|
>
|
|
{{ pensee.label }}<span v-if="pensee.confiance === 'ia_suggested'" class="ml-1 opacity-60">~</span>
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Projets emblématiques -->
|
|
<div v-if="projetsStructure.length" class="mb-4">
|
|
<h3 class="text-xs font-bold uppercase tracking-wide mb-2" style="color: var(--nav-text-muted);">Projets emblématiques</h3>
|
|
<div class="space-y-2">
|
|
<div
|
|
v-for="projet in projetsStructure.slice(0, 5)"
|
|
:key="projet.id"
|
|
class="rounded-lg p-3"
|
|
style="background: var(--nav-surface); border: 1px solid var(--nav-bg-alt);"
|
|
>
|
|
<div class="flex items-start justify-between gap-2">
|
|
<span class="font-medium text-sm" style="color: var(--nav-text);">{{ projet.nom }}</span>
|
|
<span v-if="projet.annee" class="text-xs shrink-0" style="color: var(--nav-text-muted);">{{ projet.annee }}</span>
|
|
</div>
|
|
<div v-if="projet.lieu" class="text-xs mt-0.5" style="color: var(--nav-text-muted);">{{ projet.lieu }}</div>
|
|
<p class="text-xs mt-1 leading-relaxed" style="color: var(--nav-text-muted);">{{ projet.description.slice(0, 120) }}{{ projet.description.length > 120 ? '…' : '' }}</p>
|
|
<a
|
|
v-if="projet.url"
|
|
:href="projet.url"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
class="text-xs mt-1 inline-block"
|
|
style="color: var(--nav-text-muted); text-decoration: underline;"
|
|
>En savoir plus →</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Structures voisines (graphe) -->
|
|
<div v-if="structuresVoisines.length" class="mb-4">
|
|
<h3 class="text-xs font-bold uppercase tracking-wide mb-2" style="color: var(--nav-text-muted);">Structures liées</h3>
|
|
<div class="flex flex-wrap gap-2">
|
|
<button
|
|
v-for="voisine in structuresVoisines.slice(0, 6)"
|
|
:key="voisine.id"
|
|
class="px-2 py-1 rounded text-xs transition-colors hover:opacity-70"
|
|
style="background: var(--nav-bg-alt); color: var(--nav-text); border: 1px solid transparent;"
|
|
@click="emit('update:structureId', voisine.id)"
|
|
>{{ voisine.nom }}</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Sources -->
|
|
<div v-if="structure.sources && structure.sources.length" class="mb-4">
|
|
<h3 class="text-xs font-bold uppercase tracking-wide mb-2" style="color: var(--nav-text-muted);">Sources</h3>
|
|
<div class="space-y-1">
|
|
<div v-for="(source, i) in structure.sources" :key="i" class="flex items-center gap-2">
|
|
<span
|
|
class="px-1.5 py-0.5 rounded text-xs shrink-0"
|
|
style="background: var(--nav-bg-alt); color: var(--nav-text-muted);"
|
|
>{{ source.type }}</span>
|
|
<a
|
|
v-if="source.url"
|
|
:href="source.url"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
class="text-xs underline truncate"
|
|
style="color: var(--nav-text);"
|
|
>{{ source.titre }}</a>
|
|
<span v-else class="text-xs" style="color: var(--nav-text);">{{ source.titre }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- CTAs -->
|
|
<div class="flex gap-3 pt-2" style="border-top: 1px solid var(--nav-bg-alt);">
|
|
<a
|
|
href="/contribuer"
|
|
class="text-xs underline"
|
|
style="color: var(--nav-text-muted);"
|
|
>Signaler une erreur</a>
|
|
<a
|
|
href="/contribuer"
|
|
class="text-xs underline"
|
|
style="color: var(--nav-text-muted);"
|
|
>Réclamer cette fiche</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Transition>
|
|
</Teleport>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import type { ReseauxBifurcationData, StructureV2, ProjetEmblematique } from '~/types/structure-v2'
|
|
|
|
const FAMILLE_COLORS: Record<number, string> = {
|
|
1: '#a85d3e',
|
|
2: '#c4a472',
|
|
3: '#d4a017',
|
|
4: '#5a7a4a',
|
|
5: '#3d6a8c',
|
|
}
|
|
|
|
const FAMILLE_LABELS: Record<number, string> = {
|
|
1: 'Réemploi & filières',
|
|
2: 'Frugalité & low-tech',
|
|
3: 'Architecture sociale',
|
|
4: 'Collectifs & AMO',
|
|
5: 'Urbanisme de transition',
|
|
}
|
|
|
|
const props = defineProps<{
|
|
modelValue: boolean
|
|
structureId: string | null
|
|
data: ReseauxBifurcationData | null
|
|
}>()
|
|
|
|
const emit = defineEmits<{
|
|
'update:modelValue': [value: boolean]
|
|
'update:structureId': [id: string]
|
|
}>()
|
|
|
|
const showFullDesc = ref(false)
|
|
|
|
function close() {
|
|
emit('update:modelValue', false)
|
|
showFullDesc.value = false
|
|
}
|
|
|
|
// Fermeture Esc globale
|
|
onMounted(() => {
|
|
const handler = (e: KeyboardEvent) => {
|
|
if (e.key === 'Escape' && props.modelValue) close()
|
|
}
|
|
window.addEventListener('keydown', handler)
|
|
onUnmounted(() => window.removeEventListener('keydown', handler))
|
|
})
|
|
|
|
// Remettre showFullDesc a false a chaque changement de fiche
|
|
watch(() => props.structureId, () => {
|
|
showFullDesc.value = false
|
|
})
|
|
|
|
const structure = computed<StructureV2 | null>(() => {
|
|
if (!props.data || !props.structureId) return null
|
|
return props.data.structures.find(s => s.id === props.structureId) ?? null
|
|
})
|
|
|
|
const familleColor = computed(() =>
|
|
FAMILLE_COLORS[structure.value?.famille_principale ?? 1] ?? '#888'
|
|
)
|
|
|
|
const familleLabel = computed(() =>
|
|
FAMILLE_LABELS[structure.value?.famille_principale ?? 1] ?? ''
|
|
)
|
|
|
|
const projetsStructure = computed<ProjetEmblematique[]>(() => {
|
|
if (!props.data || !props.structureId) return []
|
|
return props.data.projets?.filter(p => p.structure_parent === props.structureId) ?? []
|
|
})
|
|
|
|
const structuresVoisines = computed<StructureV2[]>(() => {
|
|
if (!props.data || !props.structureId) return []
|
|
const edges = props.data.graphe?.edges ?? []
|
|
const voisineIds = edges
|
|
.filter(e => e.source === props.structureId || e.target === props.structureId)
|
|
.sort((a, b) => (b.score ?? 0) - (a.score ?? 0))
|
|
.map(e => e.source === props.structureId ? e.target : e.source)
|
|
.slice(0, 8)
|
|
|
|
return voisineIds
|
|
.map(id => props.data!.structures.find(s => s.id === id))
|
|
.filter(Boolean) as StructureV2[]
|
|
})
|
|
</script>
|
|
|
|
<style scoped>
|
|
/* Backdrop */
|
|
.backdrop-enter-active, .backdrop-leave-active { transition: opacity 0.2s ease; }
|
|
.backdrop-enter-from, .backdrop-leave-to { opacity: 0; }
|
|
|
|
/* Modal */
|
|
.modal-enter-active, .modal-leave-active {
|
|
transition: opacity 0.2s ease, transform 0.2s ease;
|
|
}
|
|
.modal-enter-from, .modal-leave-to {
|
|
opacity: 0;
|
|
transform: translate(-50%, -52%);
|
|
}
|
|
|
|
@media (prefers-reduced-motion: reduce) {
|
|
.backdrop-enter-active, .backdrop-leave-active { transition: none; }
|
|
.modal-enter-active, .modal-leave-active { transition: none; }
|
|
}
|
|
</style>
|