feat(fiches): / et /agences ouvrent la fiche dans le panneau ; FicheReseauContenu extrait, FicheModal et FicheModalV2 retirées ; Voir sur la carte (B12-M4)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AGNSEFZdoKvbdsGyfSnu5S
This commit is contained in:
Jules Neny
2026-10-03 08:57:16 +02:00
co-authored by Claude Opus 5.5
parent 1bb55538a4
commit 5e0027b861
8 changed files with 427 additions and 598 deletions
+30 -9
View File
@@ -6,12 +6,14 @@
style="background: var(--nav-surface); border: 1px solid var(--nav-bg-alt);"
>
<!-- Bandeau titre -->
<div class="px-6 pt-6 pb-4" style="border-bottom: 1px solid var(--nav-bg-alt);">
<div class="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3 mb-3">
<h1
class="text-2xl font-bold leading-snug"
<div :class="compact ? 'px-4 pt-4 pb-3' : 'px-6 pt-6 pb-4'" style="border-bottom: 1px solid var(--nav-bg-alt);">
<div class="flex flex-col gap-3 mb-3" :class="compact ? '' : 'sm:flex-row sm:items-start sm:justify-between'">
<component
:is="compact ? 'h2' : 'h1'"
class="font-bold leading-snug"
:class="compact ? 'text-xl' : 'text-2xl'"
style="color: var(--nav-text);"
>{{ org.nom }}</h1>
>{{ org.nom }}</component>
<a
v-if="org.url"
@@ -44,7 +46,17 @@
</div>
<!-- Tags fonction -->
<div v-if="fonctionTags.length" class="flex flex-wrap gap-1.5">
<ChipGroup v-if="fonctionTags.length && etiquettesCliquables" :aria-label="`Fonctions de ${org.nom}`">
<Chip
v-for="tag in fonctionTags"
:key="tag"
variant="fonction"
:label="tag"
:aria-label="`Filtrer par ${tag}`"
@toggle="emit('etiquette', tag)"
/>
</ChipGroup>
<div v-else-if="fonctionTags.length" class="flex flex-wrap gap-1.5">
<span
v-for="tag in fonctionTags"
:key="tag"
@@ -55,7 +67,7 @@
</div>
<!-- Corps : description + mini-carte -->
<div class="p-6 flex flex-col lg:flex-row gap-6">
<div class="flex flex-col gap-6" :class="compact ? 'p-4' : 'p-6 lg:flex-row'">
<!-- Descriptions -->
<div class="flex-1 min-w-0">
@@ -106,7 +118,8 @@
<!-- Mini-carte Leaflet -->
<div
v-if="hasCoords"
class="shrink-0 lg:w-56 xl:w-64 rounded-xl overflow-hidden"
class="shrink-0 rounded-xl overflow-hidden"
:class="compact ? '' : 'lg:w-56 xl:w-64'"
style="height: 180px; border: 1px solid var(--nav-bg-alt);"
>
<ClientOnly>
@@ -186,7 +199,15 @@
const baseTiles = useBaseTiles()
import type { Org } from '~/types/org'
const props = defineProps<{ org: Org }>()
const props = defineProps<{
org: Org
/** Panneau de détail de la vue fiches (B12, 380 px) : tout en colonne, titre en h2. */
compact?: boolean
/** Fonctions en chips cliquables : la vue fiches ajoute le filtre. */
etiquettesCliquables?: boolean
}>()
const emit = defineEmits<{ etiquette: [fonction: string] }>()
// ── Champs ──────────────────────────────────────────────────────────────
const descriptionUser = computed(() =>
-183
View File
@@ -1,183 +0,0 @@
<template>
<Teleport to="body">
<!-- Backdrop -->
<Transition name="backdrop">
<div
v-if="modelValue && orgId != null"
class="fixed inset-0 z-[1500]"
style="background: rgba(26,34,56,0.55);"
@click="close"
aria-hidden="true"
/>
</Transition>
<!-- Modal centré -->
<Transition name="modal">
<div
v-if="modelValue && orgId != null"
class="fiche-modal fixed z-[1501] left-1/2 -translate-x-1/2 flex flex-col"
style="
width: min(768px, 92vw);
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="org?.nom ?? 'Fiche organisation'"
@keydown.esc="close"
>
<!-- Header modal -->
<div
class="flex items-center justify-between px-5 py-3 shrink-0 border-b"
style="background: var(--nav-surface); border-color: var(--nav-bg-alt);"
>
<span class="text-sm font-semibold" style="color: var(--nav-text-muted);">Fiche détaillée</span>
<div class="flex items-center gap-2">
<!-- Lien fiche complète -->
<a
v-if="orgId"
:href="`/fiche/${orgId}`"
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>
Ouvrir
</a>
<!-- Fermer -->
<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">
<!-- Chargement -->
<div v-if="pending" class="py-12 text-center text-sm" style="color: var(--nav-text-muted);">
Chargement de la fiche…
</div>
<!-- Erreur -->
<div v-else-if="error" class="py-12 text-center">
<p class="text-base font-semibold mb-2" style="color: var(--nav-text);">Fiche introuvable</p>
<p class="text-sm" style="color: var(--nav-text-muted);">L'organisation demandée n'existe pas ou a été supprimée.</p>
</div>
<!-- Contenu -->
<template v-else-if="org">
<FicheDetail :org="org" />
<div class="mb-5" style="height: 1px; background: var(--nav-bg-alt);"></div>
<CommentSection :org-id="org.Id" :refresh="commentRefreshTick" />
<CommentForm :org-id="org.Id" @submitted="onCommentSubmitted" />
</template>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import type { Org } from '~/types/org'
const props = defineProps<{
modelValue: boolean
orgId: number | null
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
}>()
function close() {
emit('update:modelValue', 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))
})
// Fetch fiche quand orgId change
const org = ref<Org | null>(null)
const pending = ref(false)
const error = ref(false)
watch(() => props.orgId, async (id) => {
if (id == null) return
pending.value = true
error.value = false
org.value = null
try {
org.value = await $fetch<Org>(`/api/fiche/${id}`)
} catch {
error.value = true
} finally {
pending.value = false
}
}, { immediate: true })
const commentRefreshTick = ref(0)
function onCommentSubmitted() {
commentRefreshTick.value++
}
</script>
<style scoped>
/* Modal positionnement : centré desktop, descendu sous le header sur mobile */
.fiche-modal {
top: 50%;
transform: translate(-50%, -50%);
max-height: 90vh;
}
@media (max-width: 1023px) {
.fiche-modal {
top: 76px;
transform: translateX(-50%);
max-height: calc(100dvh - 92px);
}
}
/* Backdrop */
.backdrop-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 (max-width: 1023px) {
.modal-enter-from, .modal-leave-to {
transform: translate(-50%, calc(-2% + 76px));
}
}
@media (prefers-reduced-motion: reduce) {
.backdrop-enter-active, .backdrop-leave-active { transition: none; }
.modal-enter-active, .modal-leave-active { transition: none; }
}
</style>
-360
View File
@@ -1,360 +0,0 @@
<template>
<Teleport to="body">
<!-- Backdrop -->
<Transition name="backdrop">
<div
v-if="modelValue && structureId != null"
class="fixed inset-0 z-[1500]"
style="background: rgba(26,34,56,0.55);"
@click="close"
aria-hidden="true"
/>
</Transition>
<!-- Modal -->
<Transition name="modal">
<div
v-if="modelValue && structureId != null && structure"
class="fiche-modal-v2 fixed z-[1501] left-1/2 -translate-x-1/2 flex flex-col"
style="
width: min(780px, 94vw);
background: var(--nav-bg);
border-radius: 16px;
box-shadow: 0 16px 64px rgba(26,34,56,0.28);
overflow: hidden;
"
role="dialog"
aria-modal="true"
:aria-label="structure?.nom ?? 'Fiche structure'"
@keydown.esc="close"
>
<!-- Header modal -->
<div
class="flex items-center justify-between px-5 py-3 shrink-0"
:style="`border-bottom: 3px solid ${familleColor}; background: var(--nav-surface);`"
>
<div class="flex items-center gap-3">
<!-- Pastille famille -->
<div
class="w-3 h-3 rounded-full shrink-0"
:style="`background: ${familleColor};`"
/>
<span class="text-sm font-semibold" style="color: var(--nav-text-muted);">
{{ familleLabel }}
</span>
<!-- Badges -->
<div class="flex gap-1.5">
<span
v-if="structure.badges.centre_ressources"
class="px-2 py-0.5 rounded-full text-xs font-medium"
style="background: #2d8a6b22; color: #2d8a6b;"
>Centre ressources</span>
<span
v-if="structure.badges.mouvement_manifeste"
class="px-2 py-0.5 rounded-full text-xs font-medium"
style="background: #c44a2f22; color: #c44a2f;"
>Manifeste</span>
<span
v-if="structure.badges.contre_pouvoir_spatial"
class="px-2 py-0.5 rounded-full text-xs font-medium"
style="background: #1a3a6b22; color: #1a3a6b;"
>Contre-pouvoir</span>
<span
v-if="structure.badges.f6_recherche_politique"
class="px-2 py-0.5 rounded-full text-xs font-medium"
style="background: #6b3fa022; color: #6b3fa0;"
>Recherche pol.</span>
</div>
</div>
<div class="flex items-center gap-2">
<a
v-if="structure.url"
:href="structure.url"
target="_blank"
rel="noopener noreferrer"
class="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-opacity hover:opacity-70"
style="background: var(--nav-bg-alt); color: var(--nav-text);"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
<polyline points="15 3 21 3 21 9"/>
<line x1="10" y1="14" x2="21" y2="3"/>
</svg>
Site web
</a>
<button
@click="close"
class="w-8 h-8 rounded-lg flex items-center justify-center transition-colors"
style="background: var(--nav-bg-alt); color: var(--nav-text-muted);"
aria-label="Fermer"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" aria-hidden="true">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
</div>
<!-- Contenu scrollable -->
<div class="flex-1 overflow-y-auto px-5 py-5">
<!-- Nom + meta -->
<div class="mb-4">
<h2 class="text-xl font-bold mb-1" style="color: var(--nav-text);">{{ structure.nom }}</h2>
<div class="flex flex-wrap gap-2 text-sm" style="color: var(--nav-text-muted);">
<span>{{ structure.type_principal }}</span>
<span>·</span>
<span>{{ structure.ville }}, {{ structure.pays }}</span>
</div>
</div>
<!-- Hashtags -->
<div v-if="structure.hashtags.length" class="flex flex-wrap gap-1.5 mb-4">
<span
v-for="tag in structure.hashtags"
:key="tag"
class="px-2 py-0.5 rounded-full text-xs"
style="background: var(--nav-bg-alt); color: var(--nav-text-muted);"
>{{ tag }}</span>
</div>
<!-- Description courte -->
<p class="text-sm leading-relaxed mb-4" style="color: var(--nav-text);">{{ structure.description_courte }}</p>
<!-- Description longue (expandable) -->
<div v-if="structure.description_longue" class="mb-4">
<div
class="text-sm leading-relaxed"
style="color: var(--nav-text); white-space: pre-wrap;"
:style="showFullDesc ? '' : 'max-height: 120px; overflow: hidden;'"
>{{ structure.description_longue }}</div>
<button
@click="showFullDesc = !showFullDesc"
class="mt-2 text-xs underline"
style="color: var(--nav-text-muted);"
>{{ showFullDesc ? 'Réduire' : 'Lire la suite…' }}</button>
</div>
<!-- Pensées rattachées -->
<div v-if="structure.pensees && structure.pensees.length" class="mb-4">
<h3 class="text-xs font-bold uppercase tracking-wide mb-2" style="color: var(--nav-text-muted);">Pensées rattachées</h3>
<div class="flex flex-wrap gap-1.5">
<span
v-for="pensee in structure.pensees"
:key="pensee.id"
class="px-2 py-0.5 rounded text-xs"
:style="pensee.confiance === 'ia_suggested'
? 'background: var(--nav-bg-alt); color: var(--nav-text-muted); border: 1px dashed var(--nav-bg-alt);'
: 'background: var(--nav-bg-alt); color: var(--nav-text);'"
:title="pensee.confiance === 'ia_suggested' ? 'IA suggéré' : ''"
>
{{ pensee.label }}<span v-if="pensee.confiance === 'ia_suggested'" class="ml-1 opacity-60">~</span>
</span>
</div>
</div>
<!-- Projets emblématiques -->
<div v-if="projetsStructure.length" class="mb-4">
<h3 class="text-xs font-bold uppercase tracking-wide mb-2" style="color: var(--nav-text-muted);">Projets emblématiques</h3>
<div class="space-y-2">
<div
v-for="projet in projetsStructure.slice(0, 5)"
:key="projet.id"
class="rounded-lg p-3"
style="background: var(--nav-surface); border: 1px solid var(--nav-bg-alt);"
>
<div class="flex items-start justify-between gap-2">
<span class="font-medium text-sm" style="color: var(--nav-text);">{{ projet.nom }}</span>
<span v-if="projet.annee" class="text-xs shrink-0" style="color: var(--nav-text-muted);">{{ projet.annee }}</span>
</div>
<div v-if="projet.lieu" class="text-xs mt-0.5" style="color: var(--nav-text-muted);">{{ projet.lieu }}</div>
<p class="text-xs mt-1 leading-relaxed" style="color: var(--nav-text-muted);">{{ projet.description.slice(0, 120) }}{{ projet.description.length > 120 ? '…' : '' }}</p>
<a
v-if="projet.url"
:href="projet.url"
target="_blank"
rel="noopener noreferrer"
class="text-xs mt-1 inline-block"
style="color: var(--nav-text-muted); text-decoration: underline;"
>En savoir plus →</a>
</div>
</div>
</div>
<!-- Structures voisines (graphe) -->
<div v-if="structuresVoisines.length" class="mb-4">
<h3 class="text-xs font-bold uppercase tracking-wide mb-2" style="color: var(--nav-text-muted);">Structures liées</h3>
<div class="flex flex-wrap gap-2">
<button
v-for="voisine in structuresVoisines.slice(0, 6)"
:key="voisine.id"
class="px-2 py-1 rounded text-xs transition-colors hover:opacity-70"
style="background: var(--nav-bg-alt); color: var(--nav-text); border: 1px solid transparent;"
@click="emit('update:structureId', voisine.id)"
>{{ voisine.nom }}</button>
</div>
</div>
<!-- Sources -->
<div v-if="structure.sources && structure.sources.length" class="mb-4">
<h3 class="text-xs font-bold uppercase tracking-wide mb-2" style="color: var(--nav-text-muted);">Sources</h3>
<div class="space-y-1">
<div v-for="(source, i) in structure.sources" :key="i" class="flex items-center gap-2">
<span
class="px-1.5 py-0.5 rounded text-xs shrink-0"
style="background: var(--nav-bg-alt); color: var(--nav-text-muted);"
>{{ source.type }}</span>
<a
v-if="source.url"
:href="source.url"
target="_blank"
rel="noopener noreferrer"
class="text-xs underline truncate"
style="color: var(--nav-text);"
>{{ source.titre }}</a>
<span v-else class="text-xs" style="color: var(--nav-text);">{{ source.titre }}</span>
</div>
</div>
</div>
<!-- CTAs -->
<div class="flex gap-3 pt-2" style="border-top: 1px solid var(--nav-bg-alt);">
<a
href="/contribuer"
class="text-xs underline"
style="color: var(--nav-text-muted);"
>Signaler une erreur</a>
<a
href="/contribuer"
class="text-xs underline"
style="color: var(--nav-text-muted);"
>Réclamer cette fiche</a>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import type { ReseauxBifurcationData, StructureV2, ProjetEmblematique } from '~/types/structure-v2'
const FAMILLE_COLORS: Record<number, string> = {
1: '#a85d3e',
2: '#c4a472',
3: '#d4a017',
4: '#5a7a4a',
5: '#3d6a8c',
}
const FAMILLE_LABELS: Record<number, string> = {
1: 'Réemploi & filières',
2: 'Frugalité & low-tech',
3: 'Architecture sociale',
4: 'Collectifs & AMO',
5: 'Urbanisme de transition',
}
const props = defineProps<{
modelValue: boolean
structureId: string | null
data: ReseauxBifurcationData | null
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
'update:structureId': [id: string]
}>()
const showFullDesc = ref(false)
function close() {
emit('update:modelValue', false)
showFullDesc.value = false
}
// Fermeture Esc globale
onMounted(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape' && props.modelValue) close()
}
window.addEventListener('keydown', handler)
onUnmounted(() => window.removeEventListener('keydown', handler))
})
// Remettre showFullDesc a false a chaque changement de fiche
watch(() => props.structureId, () => {
showFullDesc.value = false
})
const structure = computed<StructureV2 | null>(() => {
if (!props.data || !props.structureId) return null
return props.data.structures.find(s => s.id === props.structureId) ?? null
})
const familleColor = computed(() =>
FAMILLE_COLORS[structure.value?.famille_principale ?? 1] ?? '#888'
)
const familleLabel = computed(() =>
FAMILLE_LABELS[structure.value?.famille_principale ?? 1] ?? ''
)
const projetsStructure = computed<ProjetEmblematique[]>(() => {
if (!props.data || !props.structureId) return []
return props.data.projets?.filter(p => p.structure_parent === props.structureId) ?? []
})
const structuresVoisines = computed<StructureV2[]>(() => {
if (!props.data || !props.structureId) return []
const edges = props.data.graphe?.edges ?? []
const voisineIds = edges
.filter(e => e.source === props.structureId || e.target === props.structureId)
.sort((a, b) => (b.score ?? 0) - (a.score ?? 0))
.map(e => e.source === props.structureId ? e.target : e.source)
.slice(0, 8)
return voisineIds
.map(id => props.data!.structures.find(s => s.id === id))
.filter(Boolean) as StructureV2[]
})
</script>
<style scoped>
/* Backdrop */
.backdrop-enter-active, .backdrop-leave-active { transition: opacity 0.2s ease; }
.backdrop-enter-from, .backdrop-leave-to { opacity: 0; }
/* Modal positionnement : centré desktop, descendu sous le header sur mobile */
.fiche-modal-v2 {
top: 50%;
transform: translate(-50%, -50%);
max-height: 90vh;
}
@media (max-width: 1023px) {
.fiche-modal-v2 {
top: 76px;
transform: translateX(-50%);
max-height: calc(100dvh - 92px);
}
}
/* Modal */
.modal-enter-active, .modal-leave-active {
transition: opacity 0.2s ease, transform 0.2s ease;
}
.modal-enter-from, .modal-leave-to {
opacity: 0;
transform: translate(-50%, -52%);
}
@media (max-width: 1023px) {
.modal-enter-from, .modal-leave-to {
transform: translate(-50%, calc(-2% + 76px));
}
}
@media (prefers-reduced-motion: reduce) {
.backdrop-enter-active, .backdrop-leave-active { transition: none; }
.modal-enter-active, .modal-leave-active { transition: none; }
}
</style>
+291
View File
@@ -0,0 +1,291 @@
<!--
FicheReseauContenu — B12-M4. Le corps de l'ancienne FicheModalV2 (retirée),
devenu un contenu : la vue fiches le pose dans son panneau de détail
(slot #detail de VueFiches). Badges, hashtags, descriptions, pensées,
projets, structures liées, sources.
Hashtag cliqué → `etiquette` (la vue fiches ajoute le filtre) ; structure
liée cliquée → `voisin` (la page l'ouvre dans le même panneau).
-->
<template>
<article v-if="structure" class="frc">
<div class="frc-famille" :style="`border-bottom-color: ${familleColor};`">
<span class="frc-famille__pastille" :style="`background: ${familleColor};`" aria-hidden="true" />
<span class="frc-famille__libelle">{{ familleLabel }}</span>
<span v-if="structure.badges.centre_ressources" class="frc-badge" style="background: #2d8a6b22; color: #1f6650;">Centre ressources</span>
<span v-if="structure.badges.mouvement_manifeste" class="frc-badge" style="background: #c44a2f22; color: #9a3520;">Manifeste</span>
<span v-if="structure.badges.contre_pouvoir_spatial" class="frc-badge" style="background: #1a3a6b22; color: #1a3a6b;">Contre-pouvoir</span>
<span v-if="structure.badges.f6_recherche_politique" class="frc-badge" style="background: #6b3fa022; color: #5a3388;">Recherche pol.</span>
</div>
<h2 class="frc-nom">{{ structure.nom }}</h2>
<p class="frc-meta">
<span>{{ structure.type_principal }}</span>
<span aria-hidden="true"> · </span>
<span>{{ structure.ville }}, {{ structure.pays }}</span>
</p>
<a
v-if="structure.url"
:href="structure.url"
target="_blank"
rel="noopener noreferrer"
class="frc-site"
>
<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<span class="frc-sr"> (nouvel onglet)</span>
</a>
<ChipGroup v-if="structure.hashtags.length" :aria-label="`Hashtags de ${structure.nom}`" class="frc-tags">
<Chip
v-for="tag in structure.hashtags"
:key="tag"
variant="fonction"
:label="tag"
:active="etiquettesActives?.includes(tag)"
:aria-label="`Filtrer par ${tag}`"
@toggle="emit('etiquette', tag)"
/>
</ChipGroup>
<p class="frc-texte">{{ structure.description_courte }}</p>
<div v-if="structure.description_longue" class="frc-bloc">
<div class="frc-texte frc-long" :class="{ 'frc-long--replie': !showFullDesc }">{{ structure.description_longue }}</div>
<button type="button" class="frc-lien" :aria-expanded="showFullDesc" @click="showFullDesc = !showFullDesc">
{{ showFullDesc ? 'Réduire' : 'Lire la suite…' }}
</button>
</div>
<section v-if="structure.pensees && structure.pensees.length" class="frc-bloc">
<h3 class="frc-titre">Pensées rattachées</h3>
<div class="frc-liste-inline">
<span
v-for="pensee in structure.pensees"
:key="pensee.id"
class="frc-pensee"
:class="{ 'frc-pensee--ia': pensee.confiance === 'ia_suggested' }"
:title="pensee.confiance === 'ia_suggested' ? 'IA suggéré' : ''"
>{{ pensee.label }}<span v-if="pensee.confiance === 'ia_suggested'" aria-hidden="true"> ~</span><span v-if="pensee.confiance === 'ia_suggested'" class="frc-sr"> (suggéré par l'IA)</span></span>
</div>
</section>
<section v-if="projetsStructure.length" class="frc-bloc">
<h3 class="frc-titre">Projets emblématiques</h3>
<div class="frc-projets">
<div v-for="projet in projetsStructure.slice(0, 5)" :key="projet.id" class="frc-projet">
<div class="frc-projet__tete">
<span class="frc-projet__nom">{{ projet.nom }}</span>
<span v-if="projet.annee" class="frc-discret">{{ projet.annee }}</span>
</div>
<div v-if="projet.lieu" class="frc-discret">{{ projet.lieu }}</div>
<p class="frc-projet__desc">{{ projet.description.slice(0, 120) }}{{ projet.description.length > 120 ? '…' : '' }}</p>
<a v-if="projet.url" :href="projet.url" target="_blank" rel="noopener noreferrer" class="frc-lien">En savoir plus<span class="frc-sr"> sur {{ projet.nom }} (nouvel onglet)</span> →</a>
</div>
</div>
</section>
<section v-if="structuresVoisines.length" class="frc-bloc">
<h3 class="frc-titre">Structures liées</h3>
<div class="frc-liste-inline">
<button
v-for="voisine in structuresVoisines.slice(0, 6)"
:key="voisine.id"
type="button"
class="frc-voisin"
@click="emit('voisin', voisine.id)"
>{{ voisine.nom }}</button>
</div>
</section>
<section v-if="structure.sources && structure.sources.length" class="frc-bloc">
<h3 class="frc-titre">Sources</h3>
<ul class="frc-sources">
<li v-for="(source, i) in structure.sources" :key="i">
<span class="frc-source__type">{{ source.type }}</span>
<a v-if="source.url" :href="source.url" target="_blank" rel="noopener noreferrer" class="frc-lien">{{ source.titre }}</a>
<span v-else>{{ source.titre }}</span>
</li>
</ul>
</section>
<p class="frc-cta">
<NuxtLink to="/contribuer" class="frc-lien">Signaler une erreur</NuxtLink>
<NuxtLink to="/contribuer" class="frc-lien">Réclamer cette fiche</NuxtLink>
</p>
</article>
</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<{
structureId: string
data: ReseauxBifurcationData | null
/** Hashtags actifs dans les filtres (avec leur « # ») : la chip s'allume. */
etiquettesActives?: string[]
}>()
const emit = defineEmits<{
etiquette: [hashtag: string]
voisin: [id: string]
}>()
const showFullDesc = ref(false)
watch(() => props.structureId, () => { showFullDesc.value = false })
const structure = computed<StructureV2 | null>(() =>
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[]>(() =>
props.data?.projets?.filter((p) => p.structure_parent === props.structureId) ?? [],
)
const structuresVoisines = computed<StructureV2[]>(() => {
if (!props.data) return []
const edges = props.data.graphe?.edges ?? []
return 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)
.map((id) => props.data!.structures.find((s) => s.id === id))
.filter(Boolean) as StructureV2[]
})
</script>
<style scoped>
.frc { color: var(--text); font-size: var(--fs-sm); }
.frc-sr {
position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0;
}
.frc-famille {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-2xs) var(--space-xs);
padding-bottom: var(--space-xs);
margin-bottom: var(--space-sm);
border-bottom: 3px solid;
}
.frc-famille__pastille { width: 12px; height: 12px; border-radius: 50%; flex-shrink: 0; }
.frc-famille__libelle { font-weight: var(--fw-semibold); color: color-mix(in srgb, var(--text) 78%, transparent); }
.frc-badge {
padding: 2px var(--space-xs);
border-radius: var(--radius-full);
font-size: var(--fs-xs, 0.75rem);
font-weight: var(--fw-medium);
}
.frc-nom { margin: 0 0 var(--space-2xs); font-size: 1.25rem; font-weight: var(--fw-bold, 700); line-height: 1.25; }
.frc-meta { margin: 0 0 var(--space-sm); color: color-mix(in srgb, var(--text) 78%, transparent); }
.frc-site {
display: inline-flex;
align-items: center;
gap: var(--space-2xs);
min-height: 32px;
padding: 0 var(--space-sm);
margin-bottom: var(--space-sm);
border-radius: var(--radius-md);
background: var(--bg-alt);
color: var(--text);
font-weight: var(--fw-medium);
text-decoration: none;
}
.frc-tags { margin-bottom: var(--space-sm); }
.frc-texte { margin: 0 0 var(--space-sm); line-height: var(--lh-base, 1.55); }
.frc-long { white-space: pre-wrap; margin: 0; }
.frc-long--replie { max-height: 120px; overflow: hidden; }
.frc-bloc { margin-bottom: var(--space-md); }
.frc-titre {
margin: 0 0 var(--space-xs);
font-size: var(--fs-xs, 0.75rem);
font-weight: var(--fw-bold, 700);
text-transform: uppercase;
letter-spacing: 0.04em;
color: color-mix(in srgb, var(--text) 78%, transparent);
}
.frc-liste-inline { display: flex; flex-wrap: wrap; gap: var(--space-2xs); }
.frc-pensee {
padding: 2px var(--space-xs);
border-radius: var(--radius-sm);
background: var(--bg-alt);
font-size: var(--fs-xs, 0.75rem);
}
.frc-pensee--ia { color: color-mix(in srgb, var(--text) 78%, transparent); border: 1px dashed var(--border); }
.frc-projets { display: flex; flex-direction: column; gap: var(--space-xs); }
.frc-projet { padding: var(--space-sm); border-radius: var(--radius-md); background: var(--bg); border: 1px solid var(--border); }
.frc-projet__tete { display: flex; justify-content: space-between; gap: var(--space-xs); }
.frc-projet__nom { font-weight: var(--fw-medium); }
.frc-projet__desc { margin: var(--space-2xs) 0; font-size: var(--fs-xs, 0.75rem); line-height: 1.5; color: color-mix(in srgb, var(--text) 78%, transparent); }
.frc-discret { font-size: var(--fs-xs, 0.75rem); color: color-mix(in srgb, var(--text) 78%, transparent); }
.frc-voisin {
min-height: 32px;
padding: 0 var(--space-xs);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg-alt);
color: var(--text);
font: inherit;
font-size: var(--fs-xs, 0.75rem);
cursor: pointer;
text-align: left;
}
.frc-sources { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: var(--space-2xs); }
.frc-sources li { display: flex; align-items: baseline; gap: var(--space-xs); min-width: 0; font-size: var(--fs-xs, 0.75rem); overflow-wrap: anywhere; }
.frc-source__type { flex-shrink: 0; padding: 1px var(--space-2xs); border-radius: var(--radius-sm); background: var(--bg-alt); }
.frc-lien {
padding: 0;
border: none;
background: none;
color: var(--text);
font: inherit;
font-size: var(--fs-xs, 0.75rem);
text-decoration: underline;
cursor: pointer;
}
.frc-cta { display: flex; gap: var(--space-sm); padding-top: var(--space-xs); margin: 0; border-top: 1px solid var(--border); }
.frc-site:focus-visible,
.frc-lien:focus-visible,
.frc-voisin:focus-visible {
outline: 2px solid var(--focus-ring);
outline-offset: 2px;
}
</style>
+18
View File
@@ -26,6 +26,8 @@ const props = defineProps<{
selectedId?: number | null
/** Fiche survolée dans la liste (B11) : épingle safran, sans recentrer. */
survolId?: number | null
/** « Voir sur la carte » (B12) : zoome jusqu'à l'épingle, sortie de son cluster ; `n` change à chaque demande. */
centrage?: { id: number; n: number } | null
}>()
const emit = defineEmits<{
@@ -275,4 +277,20 @@ onUnmounted(() => {
mapInstance = null
}
})
// « Voir sur la carte » (B12) : la carte vient peut-être de s'afficher (Fiches → Mixte) ;
// on attend sa taille réelle, puis on sort l'épingle de son cluster.
watch(
() => props.centrage?.n,
() => {
const c = props.centrage
if (!c) return
setTimeout(() => {
if (!mapInstance || !clusterGroup) return
mapInstance.invalidateSize({ pan: false })
const m = markers.get(c.id)
if (m) clusterGroup.zoomToShowLayer(m, () => mapInstance?.panTo(m.getLatLng(), { animate: true }))
}, 260)
},
)
</script>
+18
View File
@@ -23,6 +23,8 @@ const props = defineProps<{
selectedId?: string | null
/** Fiche survolée dans la liste (B11) : épingle cerclée de safran, sans recentrer. */
survolId?: string | null
/** « Voir sur la carte » (B12) : zoome jusqu'à l'épingle, sortie de son cluster ; `n` change à chaque demande. */
centrage?: { id: string; n: number } | null
}>()
const emit = defineEmits<{
@@ -272,4 +274,20 @@ onUnmounted(() => {
mapInstance = null
}
})
// « Voir sur la carte » (B12) : la carte vient peut-être de s'afficher (Fiches → Mixte) ;
// on attend sa taille réelle, puis on sort l'épingle de son cluster.
watch(
() => props.centrage?.n,
() => {
const c = props.centrage
if (!c) return
setTimeout(() => {
if (!mapInstance || !clusterGroup) return
mapInstance.invalidateSize({ pan: false })
const m = markers.get(c.id)
if (m) clusterGroup.zoomToShowLayer(m, () => mapInstance?.panTo(m.getLatLng(), { animate: true }))
}, 260)
},
)
</script>