feat(aep): carte AEP — push Gitea 2026-04-28

This commit is contained in:
Jules Neny
2026-04-28 14:00:05 +02:00
commit 21c44d8193
86 changed files with 31855 additions and 0 deletions

587
components/BandeauBas.vue Normal file
View File

@@ -0,0 +1,587 @@
<template>
<!-- BANDEAU BAS -->
<!-- DESKTOP uniquement (1024px) mobile a le FAB séparé -->
<footer
v-if="!isMobile"
ref="bandeauEl"
class="bandeau-bas shrink-0"
:class="{ 'bandeau-collapsed': isCollapsed }"
aria-label="Informations projet AEP"
@mouseenter="onMouseEnter"
@mouseleave="onMouseLeave"
>
<!-- Contenu plein -->
<div class="bandeau-inner" :class="{ 'bandeau-inner--hidden': isCollapsed }">
<!-- GAUCHE : Transparence IA -->
<div class="bandeau-col">
<p class="bandeau-label">Transparence IA</p>
<template v-if="stats">
<p class="bandeau-value">
Coût IA ce mois : <strong>{{ stats.cout_mois_eur.toFixed(2) }} </strong>
&nbsp;·&nbsp;
Tokens : <strong>{{ stats.tokens_mois.toLocaleString('fr-FR') }}</strong>
</p>
<!-- Jauge -->
<div class="jauge-track" aria-label="Budget IA consommé" role="progressbar" :aria-valuenow="jaugePct" aria-valuemin="0" aria-valuemax="100">
<div class="jauge-fill" :style="{ width: jaugePct + '%' }" />
</div>
<p class="bandeau-sub">
{{ stats.requetes_mois }} requête{{ stats.requetes_mois !== 1 ? 's' : '' }} ce mois
</p>
</template>
<template v-else-if="loading">
<p class="bandeau-sub">Chargement</p>
</template>
<template v-else>
<p class="bandeau-sub">Données indisponibles</p>
</template>
</div>
<!-- MILIEU : CTA Soutien -->
<div class="bandeau-col bandeau-col--center">
<div class="soutenir-wrap">
<button
class="btn-soutenir"
type="button"
@click="modalOpen = true"
@mouseenter="tooltipVisible = true"
@mouseleave="tooltipVisible = false"
@focus="tooltipVisible = true"
@blur="tooltipVisible = false"
aria-label="Soutenir le projet AEP sur Liberapay"
>
Soutenir le projet
</button>
<!-- Tooltip au hover -->
<div v-if="tooltipVisible" class="soutenir-tooltip" role="tooltip">
1 = 30 fiches mises en ligne
</div>
</div>
</div>
<!-- DROITE : Compteurs semaine -->
<div class="bandeau-col bandeau-col--right">
<p class="bandeau-label">Cette semaine</p>
<template v-if="stats">
<p class="bandeau-value">
{{ stats.fiches_semaine }} fiche{{ stats.fiches_semaine !== 1 ? 's' : '' }} ajoutée{{ stats.fiches_semaine !== 1 ? 's' : '' }}
</p>
<p class="bandeau-sub">
{{ stats.requetes_chatbot_semaine }} requête{{ stats.requetes_chatbot_semaine !== 1 ? 's' : '' }} chatbot
</p>
</template>
<template v-else>
<p class="bandeau-sub"></p>
</template>
</div>
</div>
<!-- Barre fine (état collapsed) -->
<div class="bandeau-thin" :class="{ 'bandeau-thin--visible': isCollapsed }">
<span class="bandeau-thin-label">AEP · Transparence IA</span>
</div>
<!-- MODAL Liberapay -->
<Teleport to="body">
<Transition name="backdrop">
<div
v-if="modalOpen"
class="modal-backdrop"
@click="modalOpen = false"
aria-hidden="true"
/>
</Transition>
<Transition name="modal">
<div
v-if="modalOpen"
class="modal-box"
role="dialog"
aria-modal="true"
aria-label="Soutenir AEP sur Liberapay"
>
<button
class="modal-close"
type="button"
@click="modalOpen = false"
aria-label="Fermer"
>
<svg width="16" height="16" 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>
<h2 class="modal-title">Soutenir AEP</h2>
<p class="modal-desc">
AEP est un outil libre, sans publicité, financé par les dons.
1 finance environ 30 fiches mises en ligne.
</p>
<div class="modal-widget">
<iframe
src="https://liberapay.com/trans-former.fr/widgets/button.html"
width="95"
height="22"
style="border: 0;"
title="Faire un don sur Liberapay"
/>
</div>
<a
href="https://liberapay.com/trans-former.fr/donate"
target="_blank"
rel="noopener noreferrer"
class="modal-link"
>
Faire un don sur Liberapay
</a>
</div>
</Transition>
</Teleport>
</footer>
<!-- FAB MOBILE (< 1024px) -->
<div v-else>
<!-- FAB soutenir (à gauche du chatbot) -->
<button
class="fab-soutenir"
type="button"
@click="fabSheetOpen = true"
aria-label="Soutenir le projet AEP"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/>
</svg>
</button>
<!-- Bottom sheet FAB -->
<Teleport to="body">
<Transition name="backdrop">
<div
v-if="fabSheetOpen"
class="fixed inset-0 z-[1020]"
style="background: rgba(26,34,56,0.5);"
@click="fabSheetOpen = false"
aria-hidden="true"
/>
</Transition>
<Transition name="sheet">
<div
v-if="fabSheetOpen"
class="fab-sheet"
role="dialog"
aria-modal="true"
aria-label="Soutenir AEP"
>
<!-- Poignée -->
<div class="flex justify-center pt-3 pb-1">
<div class="rounded-full" style="width: 36px; height: 4px; background: var(--nav-bg-alt);" />
</div>
<div class="px-5 pb-6">
<h2 class="text-base font-bold mb-2" style="color: var(--nav-text);">Soutenir AEP</h2>
<template v-if="stats">
<p class="text-sm mb-1" style="color: var(--nav-text-muted);">
Coût IA ce mois : <strong>{{ stats.cout_mois_eur.toFixed(2) }} </strong>
· Tokens : {{ stats.tokens_mois.toLocaleString('fr-FR') }}
</p>
<p class="text-sm mb-3" style="color: var(--nav-text-muted);">
{{ stats.fiches_semaine }} fiche{{ stats.fiches_semaine !== 1 ? 's' : '' }} ajoutée{{ stats.fiches_semaine !== 1 ? 's' : '' }} cette semaine
</p>
</template>
<p class="text-sm mb-4" style="color: var(--nav-text-muted); line-height: 1.5;">
1 = 30 fiches mises en ligne. AEP est libre, sans pub, financé par les dons.
</p>
<a
href="https://liberapay.com/trans-former.fr/donate"
target="_blank"
rel="noopener noreferrer"
class="block w-full text-center py-3 rounded-xl font-semibold text-sm"
style="background: var(--nav-primary); color: var(--nav-text-on-primary); text-decoration: none;"
@click="fabSheetOpen = false"
>
Soutenir sur Liberapay
</a>
</div>
</div>
</Transition>
</Teleport>
</div>
</template>
<script setup lang="ts">
interface Stats {
cout_mois_eur: number
budget_mois: number
tokens_mois: number
co2_kg: number
requetes_mois: number
fiches_semaine: number
requetes_chatbot_semaine: number
}
const stats = ref<Stats | null>(null)
const loading = ref(true)
const modalOpen = ref(false)
const fabSheetOpen = ref(false)
const tooltipVisible = ref(false)
// Desktop — replié par défaut, déploie au hover, replie immédiatement à la sortie
const bandeauEl = ref<HTMLElement | null>(null)
const isCollapsed = ref(true) // replié par défaut
const REFRESH_MS = 5 * 60 * 1000 // 5 minutes
// Détection mobile côté client
const isMobile = ref(false)
onMounted(() => {
isMobile.value = window.innerWidth < 1024
const handleResize = () => {
isMobile.value = window.innerWidth < 1024
}
window.addEventListener('resize', handleResize)
onUnmounted(() => window.removeEventListener('resize', handleResize))
fetchStats()
const interval = setInterval(fetchStats, REFRESH_MS)
onUnmounted(() => clearInterval(interval))
})
function onMouseEnter() {
isCollapsed.value = false
}
function onMouseLeave() {
// Repli immédiat — pas de timer
isCollapsed.value = true
}
async function fetchStats() {
try {
const res = await $fetch<Stats>('/api/stats')
stats.value = res
} catch {
stats.value = null
} finally {
loading.value = false
}
}
const jaugePct = computed(() => {
if (!stats.value) return 0
return Math.min(100, Math.round((stats.value.cout_mois_eur / stats.value.budget_mois) * 100))
})
</script>
<style scoped>
/* ── Bandeau bas ─────────────────────────────────────────────────────────── */
.bandeau-bas {
background: rgba(26, 34, 56, 0.7); /* opacité 70% */
color: var(--nav-text-on-primary);
font-family: var(--nav-font, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif);
border-top: 1px solid rgba(255, 255, 255, 0.08);
transition: min-height 0.25s ease;
position: relative;
overflow: hidden;
}
.bandeau-collapsed {
min-height: 32px !important;
cursor: pointer;
}
.bandeau-inner {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
padding: 10px 20px;
max-width: 1400px;
margin: 0 auto;
min-height: 64px;
transition: opacity 0.2s ease, max-height 0.3s ease;
max-height: 200px;
opacity: 1;
}
.bandeau-inner--hidden {
opacity: 0;
max-height: 0;
overflow: hidden;
padding-top: 0;
padding-bottom: 0;
min-height: 0;
}
/* Barre fine (collapsed) */
.bandeau-thin {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
pointer-events: none;
transition: opacity 0.2s ease;
}
.bandeau-thin--visible {
opacity: 1;
}
.bandeau-thin-label {
font-size: 0.65rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
opacity: 0.7;
}
/* ── Colonnes ────────────────────────────────────────────────────────────── */
.bandeau-col {
flex: 1;
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
}
.bandeau-col--center {
align-items: center;
justify-content: center;
flex: 0 0 auto;
padding-top: 8px; /* décaler légèrement vers le bas pour mieux centrer dans la hauteur */
}
.bandeau-col--right {
align-items: flex-end;
}
/* ── Typo ────────────────────────────────────────────────────────────────── */
.bandeau-label {
font-size: 0.65rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.07em;
opacity: 0.65;
margin: 0;
line-height: 1.3;
}
.bandeau-value {
font-size: 0.775rem;
font-weight: 500;
color: var(--nav-text-on-primary);
margin: 0;
line-height: 1.4;
}
.bandeau-value strong {
font-weight: 700;
}
.bandeau-sub {
font-size: 0.68rem;
opacity: 0.7;
margin: 0;
line-height: 1.4;
}
/* ── Jauge budget ────────────────────────────────────────────────────────── */
.jauge-track {
width: 100%;
max-width: 180px;
height: 4px;
border-radius: 2px;
background: rgba(255, 255, 255, 0.18);
overflow: hidden;
margin: 3px 0;
}
.jauge-fill {
height: 100%;
border-radius: 2px;
background: var(--nav-accent);
transition: width 0.6s ease;
}
/* ── Bouton soutenir + tooltip ───────────────────────────────────────────── */
.soutenir-wrap {
position: relative;
display: inline-flex;
flex-direction: column;
align-items: center;
}
.btn-soutenir {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 7px 16px;
border-radius: 8px;
border: none;
background: var(--nav-accent);
color: var(--nav-text);
font-size: 0.8rem;
font-weight: 700;
font-family: inherit;
cursor: pointer;
white-space: nowrap;
transition: opacity 0.15s, transform 0.1s;
flex-shrink: 0;
}
.btn-soutenir:hover { opacity: 0.88; transform: translateY(-1px); }
.btn-soutenir:active { opacity: 1; transform: translateY(0); }
.soutenir-tooltip {
position: absolute;
bottom: calc(100% + 8px);
left: 50%;
transform: translateX(-50%);
background: var(--nav-primary-solid, #1a2238);
color: var(--nav-text-on-primary);
font-size: 0.72rem;
font-weight: 600;
padding: 5px 10px;
border-radius: 6px;
white-space: nowrap;
pointer-events: none;
z-index: 10;
}
.soutenir-tooltip::after {
content: '';
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
border: 5px solid transparent;
border-top-color: var(--nav-primary-solid, #1a2238);
}
/* ── FAB mobile soutenir ─────────────────────────────────────────────────── */
.fab-soutenir {
position: fixed;
bottom: 68px; /* au-dessus du FAB chatbot à 24px du bas + 48px de hauteur */
left: 16px;
z-index: 1000;
width: 44px;
height: 44px;
border-radius: 50%;
border: none;
background: var(--nav-accent);
color: var(--nav-text);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 16px rgba(26,34,56,0.25);
cursor: pointer;
transition: opacity 0.15s, transform 0.1s;
}
.fab-soutenir:hover { opacity: 0.88; transform: translateY(-1px); }
/* ── Bottom sheet FAB ────────────────────────────────────────────────────── */
.fab-sheet {
position: fixed;
inset-x: 0;
bottom: 0;
z-index: 1021;
background: var(--nav-surface);
border-radius: 16px 16px 0 0;
box-shadow: 0 -4px 32px rgba(26,34,56,0.18);
}
/* ── Modal ───────────────────────────────────────────────────────────────── */
.modal-backdrop {
position: fixed;
inset: 0;
z-index: 2000;
background: rgba(26, 34, 56, 0.55);
}
.modal-box {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 2001;
background: var(--nav-surface, #ffffff);
border-radius: 16px;
padding: 28px 24px 24px;
width: min(380px, 90vw);
box-shadow: 0 8px 40px rgba(26, 34, 56, 0.22);
display: flex;
flex-direction: column;
gap: 12px;
}
.modal-close {
position: absolute;
top: 14px;
right: 14px;
width: 30px;
height: 30px;
border-radius: 8px;
border: none;
background: var(--nav-bg-alt, #eee9df);
color: var(--nav-text-muted, rgba(26,34,56,0.55));
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.15s;
}
.modal-close:hover { background: var(--nav-bg, #f8f6f1); }
.modal-title {
font-size: 1.05rem;
font-weight: 700;
color: var(--nav-text, #1a2238);
margin: 0;
}
.modal-desc {
font-size: 0.85rem;
color: var(--nav-text-muted, rgba(26,34,56,0.55));
line-height: 1.6;
margin: 0;
}
.modal-widget {
display: flex;
justify-content: center;
padding: 12px 0;
}
.modal-link {
display: block;
text-align: center;
font-size: 0.8rem;
color: var(--nav-primary-solid, #1a2238);
text-decoration: underline;
transition: opacity 0.15s;
}
.modal-link:hover { opacity: 0.7; }
/* ── Transitions ─────────────────────────────────────────────────────────── */
.backdrop-enter-active, .backdrop-leave-active { transition: opacity 0.2s ease; }
.backdrop-enter-from, .backdrop-leave-to { opacity: 0; }
.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%, -48%); }
.sheet-enter-active, .sheet-leave-active { transition: transform 0.3s cubic-bezier(0.32, 0.72, 0, 1); }
.sheet-enter-from, .sheet-leave-to { transform: translateY(100%); }
@media (prefers-reduced-motion: reduce) {
.btn-soutenir { transition: none; }
.jauge-fill { transition: none; }
.modal-enter-active, .modal-leave-active { transition: none; }
.backdrop-enter-active, .backdrop-leave-active { transition: none; }
.sheet-enter-active, .sheet-leave-active { transition: none; }
.bandeau-bas { transition: none; }
.bandeau-inner { transition: none; }
}
</style>

View File

@@ -0,0 +1,382 @@
<template>
<!-- Zone chatbot desktop sous la carte, expand/collapse -->
<div
class="chatbot-placeholder shrink-0"
:class="{ expanded: isExpanded }"
style="border-top: 1px solid var(--nav-bg-alt); background: var(--nav-bg);"
>
<!-- HEADER (toujours visible, cliquable) -->
<div
class="chatbot-header"
@click="toggleExpand"
role="button"
tabindex="0"
:aria-expanded="isExpanded"
aria-label="Ouvrir l'assistant chatbot"
@keydown.enter="toggleExpand"
@keydown.space.prevent="toggleExpand"
>
<!-- Icône chatbot -->
<div
class="shrink-0 w-7 h-7 rounded-full flex items-center justify-center"
style="background: var(--nav-primary);"
>
<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" style="color: var(--nav-text-on-primary);">
<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>
</div>
<span class="flex-1 text-sm" style="color: var(--nav-text-muted);">
{{ isExpanded ? 'Chatbot AEP' : 'Pose une question sur le réseau…' }}
</span>
<!-- Chevron -->
<button
type="button"
class="chatbot-chevron"
:aria-label="isExpanded ? 'Replier le chatbot' : 'Ouvrir le chatbot'"
@click.stop="toggleExpand"
>
<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="{ transform: isExpanded ? 'rotate(180deg)' : 'rotate(0deg)', transition: 'transform 0.3s ease' }"
>
<polyline points="18 15 12 9 6 15"/>
</svg>
</button>
</div>
<!-- ZONE ÉTENDUE -->
<div class="chatbot-body">
<div class="chatbot-body-inner" ref="messagesContainer">
<!-- Onboarding -->
<div v-if="messages.length === 0" class="onboarding-bubble">
<p>Ce chatbot fonctionne sur un serveur européen souverain
(Mistral FR, zéro rétention), conçu sobre en énergie.</p>
<p>Pour m'aider à te répondre efficacement,
formule ta requête ainsi :</p>
<ul>
<li>• Besoin : [ce que tu cherches]</li>
<li>• Thématique : [juridique / technique / économique / ...]</li>
<li>• Lieu : [région ou ville]</li>
</ul>
<p class="example">Exemple : "Je suis salarié d'agence, litige avec mon
employeur, besoin conseil juridique droit du travail,
Île-de-France."</p>
</div>
<!-- Messages -->
<template v-for="(msg, i) in messages" :key="i">
<div v-if="msg.role === 'user'" class="user-bubble">{{ msg.content }}</div>
<div v-else class="assistant-bubble">
<p>{{ msg.content }}</p>
<div v-if="msg.fiches && msg.fiches.length > 0" class="fiches-list">
<p class="fiches-title">Fiches recommandées :</p>
<a
v-for="fiche in msg.fiches"
:key="fiche.id"
:href="`/fiche/${fiche.id}`"
class="fiche-card"
>
<span class="fiche-nom">{{ fiche.nom }}</span>
<span v-if="fiche.explication" class="fiche-expl">{{ fiche.explication }}</span>
</a>
</div>
</div>
</template>
<!-- Chargement -->
<div v-if="loading" class="assistant-bubble loading-bubble">
<span class="dot" /><span class="dot" /><span class="dot" />
</div>
<!-- Erreur -->
<div v-if="errorMsg" class="error-bubble">{{ errorMsg }}</div>
</div>
<!-- Input -->
<div class="chatbot-input-row" style="border-top: 1px solid var(--nav-bg-alt);">
<input
v-model="inputText"
type="text"
:disabled="loading"
placeholder="Pose ta question"
class="chatbot-input"
@keydown.enter.prevent="sendMessage"
/>
<button
:disabled="loading || !inputText.trim()"
class="chatbot-send"
aria-label="Envoyer"
@click="sendMessage"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="color: var(--nav-text-on-primary);">
<line x1="22" y1="2" x2="11" y2="13"/>
<polygon points="22 2 15 22 11 13 2 9 22 2"/>
</svg>
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
interface FicheReco {
id: number | string
nom: string
explication?: string
}
interface ChatMessage {
role: 'user' | 'assistant'
content: string
fiches?: FicheReco[]
}
const emit = defineEmits<{
'highlightOrgs': [ids: (number | string)[]]
}>()
const isExpanded = ref(false)
const messages = ref<ChatMessage[]>([])
const inputText = ref('')
const loading = ref(false)
const errorMsg = ref('')
const messagesContainer = ref<HTMLElement | null>(null)
function toggleExpand() {
isExpanded.value = !isExpanded.value
}
async function sendMessage() {
const question = inputText.value.trim()
if (!question || loading.value) return
inputText.value = ''
errorMsg.value = ''
messages.value.push({ role: 'user', content: question })
loading.value = true
await nextTick()
scrollToBottom()
try {
const res = await $fetch<{
reponse_texte: string
fiches_recommandees: { id: number | string; nom: string; explication: string }[]
}>('/api/chatbot', {
method: 'POST',
body: { question },
})
const assistantMsg: ChatMessage = {
role: 'assistant',
content: res.reponse_texte,
fiches: res.fiches_recommandees || [],
}
messages.value.push(assistantMsg)
if (assistantMsg.fiches && assistantMsg.fiches.length > 0) {
emit('highlightOrgs', assistantMsg.fiches.map((f) => f.id))
}
} catch (e: any) {
const status = e?.statusCode ?? e?.status
if (status === 429) {
errorMsg.value = 'Limite de 10 questions par jour atteinte. Reviens demain.'
} else if (status === 503) {
errorMsg.value = 'Le budget IA mensuel est épuisé. Soutiens NAV sur Liberapay pour continuer.'
} else {
errorMsg.value = 'Une erreur est survenue. Réessaie dans quelques instants.'
}
} finally {
loading.value = false
await nextTick()
scrollToBottom()
}
}
function scrollToBottom() {
if (messagesContainer.value) {
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
}
}
</script>
<style scoped>
.chatbot-placeholder {
display: flex;
flex-direction: column;
overflow: hidden;
transition: max-height 0.3s ease;
max-height: 56px;
}
.chatbot-placeholder.expanded {
max-height: 55vh;
}
.chatbot-header {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 16px;
cursor: pointer;
min-height: 56px;
flex-shrink: 0;
user-select: none;
}
.chatbot-header:hover { background: var(--nav-surface); }
.chatbot-chevron {
display: flex;
align-items: center;
justify-content: center;
color: var(--nav-text-muted);
flex-shrink: 0;
padding: 4px;
border-radius: 6px;
border: none;
background: transparent;
cursor: pointer;
transition: color 0.15s, background 0.15s;
}
.chatbot-chevron:hover {
color: var(--nav-text);
background: var(--nav-bg-alt);
}
.chatbot-body {
flex: 1;
overflow: hidden;
border-top: 1px solid var(--nav-bg-alt);
display: none;
flex-direction: column;
}
.chatbot-placeholder.expanded .chatbot-body {
display: flex;
}
.chatbot-body-inner {
flex: 1;
overflow-y: auto;
padding: 12px 16px;
display: flex;
flex-direction: column;
gap: 8px;
}
.chatbot-input-row {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
flex-shrink: 0;
}
.chatbot-input {
flex: 1;
padding: 8px 12px;
border-radius: 10px;
border: 1px solid var(--nav-bg-alt);
background: var(--nav-surface);
color: var(--nav-text);
font-size: 0.8rem;
font-family: var(--nav-font);
}
.chatbot-send {
width: 34px;
height: 34px;
border-radius: 8px;
background: var(--nav-primary);
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: opacity 0.15s;
}
.chatbot-send:disabled { opacity: 0.4; cursor: not-allowed; }
/* Messages */
.onboarding-bubble {
background: var(--nav-bg-alt);
border-radius: 10px;
padding: 12px;
font-size: 0.78rem;
line-height: 1.6;
color: var(--nav-text-muted);
white-space: pre-line;
}
.onboarding-bubble p { margin-bottom: 8px; }
.onboarding-bubble ul { margin: 6px 0; padding: 0; list-style: none; }
.onboarding-bubble li { margin-bottom: 2px; }
.onboarding-bubble .example { font-style: italic; opacity: 0.8; font-size: 0.75rem; margin-top: 8px; }
.user-bubble {
align-self: flex-end;
max-width: 80%;
background: var(--nav-primary);
color: var(--nav-text-on-primary);
border-radius: 12px 12px 4px 12px;
padding: 8px 12px;
font-size: 0.8rem;
line-height: 1.5;
}
.assistant-bubble {
align-self: flex-start;
max-width: 92%;
background: var(--nav-surface);
border: 1px solid var(--nav-bg-alt);
border-radius: 12px 12px 12px 4px;
padding: 10px 12px;
font-size: 0.8rem;
line-height: 1.6;
color: var(--nav-text);
}
.assistant-bubble p { margin: 0; }
.fiches-list { margin-top: 8px; display: flex; flex-direction: column; gap: 4px; }
.fiches-title { font-size: 0.7rem; font-weight: 600; color: var(--nav-text-muted); text-transform: uppercase; letter-spacing: 0.04em; margin-bottom: 4px; }
.fiche-card {
display: block;
background: var(--nav-bg);
border: 1px solid var(--nav-bg-alt);
border-radius: 6px;
padding: 6px 10px;
text-decoration: none;
transition: border-color 0.15s;
}
.fiche-card:hover { border-color: var(--nav-primary); }
.fiche-nom { display: block; font-size: 0.775rem; font-weight: 600; color: var(--nav-text); }
.fiche-expl { display: block; font-size: 0.72rem; color: var(--nav-text-muted); margin-top: 1px; }
.loading-bubble { display: flex; gap: 4px; padding: 10px 14px; }
.dot {
width: 6px; height: 6px;
background: var(--nav-text-muted);
border-radius: 50%;
animation: blink 1.2s infinite ease-in-out;
}
.dot:nth-child(2) { animation-delay: 0.2s; }
.dot:nth-child(3) { animation-delay: 0.4s; }
@keyframes blink {
0%, 80%, 100% { opacity: 0.3; transform: scale(0.85); }
40% { opacity: 1; transform: scale(1); }
}
.error-bubble {
background: rgba(220, 50, 50, 0.07);
border: 1px solid rgba(220, 50, 50, 0.18);
border-radius: 8px;
padding: 8px 12px;
font-size: 0.78rem;
color: #c0392b;
text-align: center;
}
@media (prefers-reduced-motion: reduce) {
.chatbot-placeholder { transition: none; }
.dot { animation: none; opacity: 0.5; }
}
</style>

406
components/ChatbotSheet.vue Normal file
View File

@@ -0,0 +1,406 @@
<template>
<Teleport to="body">
<!-- Backdrop -->
<transition name="backdrop">
<div
v-if="modelValue"
class="fixed inset-0 z-[1010]"
style="background: rgba(26,34,56,0.5);"
@click="emit('update:modelValue', false)"
aria-hidden="true"
/>
</transition>
<!-- Bottom sheet plein écran -->
<transition name="sheet">
<div
v-if="modelValue"
class="fixed inset-x-0 bottom-0 z-[1011] flex flex-col"
style="
background: var(--nav-surface);
height: 100dvh;
max-height: 100dvh;
border-radius: 0;
box-shadow: 0 -4px 32px rgba(26,34,56,0.18);
"
role="dialog"
aria-modal="true"
aria-label="Assistant AEP"
>
<!-- Poignée visuelle -->
<div class="flex justify-center pt-3 pb-1 shrink-0">
<div
class="rounded-full"
style="width: 36px; height: 4px; background: var(--nav-bg-alt);"
/>
</div>
<!-- Header -->
<div
class="flex items-center justify-between px-4 py-3 shrink-0 border-b"
style="border-color: var(--nav-bg-alt);"
>
<button
@click="emit('update:modelValue', false)"
class="flex items-center gap-2 text-sm font-medium transition-opacity hover:opacity-70"
style="color: var(--nav-text-muted);"
aria-label="Retour"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<polyline points="15 18 9 12 15 6"/>
</svg>
Retour
</button>
<div class="flex items-center gap-2">
<div
class="w-7 h-7 rounded-full flex items-center justify-center shrink-0"
style="background: var(--nav-primary);"
>
<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" style="color: var(--nav-text-on-primary);">
<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>
</div>
<span class="font-bold text-sm" style="color: var(--nav-text);">Chatbot</span>
</div>
</div>
<!-- Zone conversation -->
<div ref="messagesContainer" class="flex-1 overflow-y-auto px-4 py-4 flex flex-col gap-3">
<!-- Message onboarding (avant la première question) -->
<div v-if="messages.length === 0" class="onboarding-bubble">
<p>Ce chatbot fonctionne sur un serveur européen souverain
(Mistral FR, zéro rétention), conçu sobre en énergie.</p>
<p>Pour m'aider à te répondre efficacement,
formule ta requête ainsi :</p>
<ul>
<li>• Besoin : [ce que tu cherches]</li>
<li>• Thématique : [juridique / technique / économique / ...]</li>
<li>• Lieu : [région ou ville]</li>
</ul>
<p class="example">Exemple : "Je suis salarié d'agence, litige avec mon
employeur, besoin conseil juridique droit du travail,
Île-de-France."</p>
</div>
<!-- Messages -->
<template v-for="(msg, i) in messages" :key="i">
<!-- Message utilisateur -->
<div v-if="msg.role === 'user'" class="user-bubble">
{{ msg.content }}
</div>
<!-- Message assistant -->
<div v-else class="assistant-bubble">
<p>{{ msg.content }}</p>
<!-- Fiches recommandées -->
<div v-if="msg.fiches && msg.fiches.length > 0" class="fiches-list">
<p class="fiches-title">Fiches recommandées :</p>
<a
v-for="fiche in msg.fiches"
:key="fiche.id"
:href="`/fiche/${fiche.id}`"
class="fiche-card"
>
<span class="fiche-nom">{{ fiche.nom }}</span>
<span v-if="fiche.explication" class="fiche-expl">{{ fiche.explication }}</span>
</a>
</div>
</div>
</template>
<!-- Indicateur de chargement -->
<div v-if="loading" class="assistant-bubble loading-bubble">
<span class="dot" /><span class="dot" /><span class="dot" />
</div>
<!-- Message d'erreur -->
<div v-if="errorMsg" class="error-bubble">
{{ errorMsg }}
</div>
</div>
<!-- Input -->
<div
class="shrink-0 px-4 pt-3 border-t"
style="border-color: var(--nav-bg-alt); padding-bottom: max(1rem, env(safe-area-inset-bottom));"
>
<div class="flex items-center gap-2">
<input
v-model="inputText"
type="text"
:disabled="loading"
placeholder="Pose ta question"
class="flex-1 px-4 py-3 rounded-xl text-sm border"
:class="loading ? 'cursor-not-allowed' : ''"
style="
border-color: var(--nav-bg-alt);
background: var(--nav-bg);
color: var(--nav-text);
font-family: var(--nav-font);
font-size: 16px;
"
@keydown.enter.prevent="sendMessage"
/>
<button
:disabled="loading || !inputText.trim()"
class="w-11 h-11 rounded-xl flex items-center justify-center shrink-0 transition-opacity"
style="background: var(--nav-primary);"
:style="{ opacity: (loading || !inputText.trim()) ? 0.4 : 1 }"
aria-label="Envoyer"
@click="sendMessage"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="color: var(--nav-text-on-primary);">
<line x1="22" y1="2" x2="11" y2="13"/>
<polygon points="22 2 15 22 11 13 2 9 22 2"/>
</svg>
</button>
</div>
</div>
</div>
</transition>
</Teleport>
</template>
<script setup lang="ts">
interface FicheReco {
id: number | string
nom: string
explication?: string
}
interface ChatMessage {
role: 'user' | 'assistant'
content: string
fiches?: FicheReco[]
}
const props = defineProps<{
modelValue: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
'highlightOrgs': [ids: (number | string)[]]
}>()
const messages = ref<ChatMessage[]>([])
const inputText = ref('')
const loading = ref(false)
const errorMsg = ref('')
const messagesContainer = ref<HTMLElement | null>(null)
watch(() => props.modelValue, (open) => {
if (typeof document === 'undefined') return
if (open) {
document.body.style.overflow = 'hidden'
document.documentElement.style.overflow = 'hidden'
} else {
document.body.style.overflow = ''
document.documentElement.style.overflow = ''
}
})
onUnmounted(() => {
if (typeof document !== 'undefined') {
document.body.style.overflow = ''
document.documentElement.style.overflow = ''
}
})
async function sendMessage() {
const question = inputText.value.trim()
if (!question || loading.value) return
inputText.value = ''
errorMsg.value = ''
messages.value.push({ role: 'user', content: question })
loading.value = true
await nextTick()
scrollToBottom()
try {
const res = await $fetch<{
reponse_texte: string
fiches_recommandees: { id: number | string; nom: string; explication: string }[]
}>('/api/chatbot', {
method: 'POST',
body: { question },
})
const assistantMsg: ChatMessage = {
role: 'assistant',
content: res.reponse_texte,
fiches: res.fiches_recommandees || [],
}
messages.value.push(assistantMsg)
// Highlight carte si des fiches sont recommandées
if (assistantMsg.fiches && assistantMsg.fiches.length > 0) {
emit('highlightOrgs', assistantMsg.fiches.map((f) => f.id))
}
} catch (e: any) {
const status = e?.statusCode ?? e?.status
if (status === 429) {
errorMsg.value = 'Limite de 10 questions par jour atteinte. Reviens demain.'
} else if (status === 503) {
errorMsg.value = 'Le budget IA mensuel est épuisé. Soutiens NAV sur Liberapay pour continuer.'
} else {
errorMsg.value = 'Une erreur est survenue. Réessaie dans quelques instants.'
}
} finally {
loading.value = false
await nextTick()
scrollToBottom()
}
}
function scrollToBottom() {
if (messagesContainer.value) {
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
}
}
</script>
<style scoped>
.backdrop-enter-active,
.backdrop-leave-active { transition: opacity 0.2s ease; }
.backdrop-enter-from,
.backdrop-leave-to { opacity: 0; }
.sheet-enter-active,
.sheet-leave-active { transition: transform 0.3s cubic-bezier(0.32, 0.72, 0, 1); }
.sheet-enter-from,
.sheet-leave-to { transform: translateY(100%); }
/* Onboarding */
.onboarding-bubble {
background: var(--nav-bg);
border: 1px solid var(--nav-bg-alt);
border-radius: 12px;
padding: 16px;
font-size: 0.85rem;
line-height: 1.65;
color: var(--nav-text-muted);
white-space: pre-line;
}
.onboarding-bubble p { margin-bottom: 10px; }
.onboarding-bubble ul { margin: 8px 0; padding: 0; list-style: none; }
.onboarding-bubble li { margin-bottom: 4px; }
.onboarding-bubble .example {
margin-top: 12px;
font-style: italic;
opacity: 0.8;
font-size: 0.8rem;
}
/* Bulles utilisateur */
.user-bubble {
align-self: flex-end;
max-width: 80%;
background: var(--nav-primary);
color: var(--nav-text-on-primary);
border-radius: 16px 16px 4px 16px;
padding: 10px 14px;
font-size: 0.875rem;
line-height: 1.5;
}
/* Bulles assistant */
.assistant-bubble {
align-self: flex-start;
max-width: 90%;
background: var(--nav-surface);
border: 1px solid var(--nav-bg-alt);
border-radius: 16px 16px 16px 4px;
padding: 12px 14px;
font-size: 0.875rem;
line-height: 1.6;
color: var(--nav-text);
}
.assistant-bubble p { margin: 0; }
/* Fiches recommandées */
.fiches-list {
margin-top: 12px;
display: flex;
flex-direction: column;
gap: 6px;
}
.fiches-title {
font-size: 0.75rem;
font-weight: 600;
color: var(--nav-text-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
margin-bottom: 4px;
}
.fiche-card {
display: block;
background: var(--nav-bg);
border: 1px solid var(--nav-bg-alt);
border-radius: 8px;
padding: 8px 12px;
text-decoration: none;
transition: border-color 0.15s, background 0.15s;
}
.fiche-card:hover {
border-color: var(--nav-primary);
background: var(--nav-surface);
}
.fiche-nom {
display: block;
font-size: 0.825rem;
font-weight: 600;
color: var(--nav-text);
}
.fiche-expl {
display: block;
font-size: 0.775rem;
color: var(--nav-text-muted);
margin-top: 2px;
}
/* Chargement */
.loading-bubble {
display: flex;
gap: 5px;
padding: 12px 16px;
}
.dot {
width: 7px;
height: 7px;
background: var(--nav-text-muted);
border-radius: 50%;
animation: blink 1.2s infinite ease-in-out;
}
.dot:nth-child(2) { animation-delay: 0.2s; }
.dot:nth-child(3) { animation-delay: 0.4s; }
@keyframes blink {
0%, 80%, 100% { opacity: 0.3; transform: scale(0.85); }
40% { opacity: 1; transform: scale(1); }
}
/* Erreur */
.error-bubble {
align-self: center;
background: rgba(220, 50, 50, 0.08);
border: 1px solid rgba(220, 50, 50, 0.2);
border-radius: 10px;
padding: 10px 14px;
font-size: 0.825rem;
color: #c0392b;
text-align: center;
max-width: 90%;
}
@media (prefers-reduced-motion: reduce) {
.sheet-enter-active,
.sheet-leave-active { transition: none; }
.backdrop-enter-active,
.backdrop-leave-active { transition: none; }
.dot { animation: none; opacity: 0.5; }
}
</style>

147
components/CommentForm.vue Normal file
View File

@@ -0,0 +1,147 @@
<template>
<section
class="rounded-2xl p-6"
style="background: var(--nav-bg-alt); border: 1px solid rgba(26,34,56,0.1);"
>
<h3 class="font-semibold mb-4" style="color: var(--nav-text);">Ajouter un commentaire</h3>
<!-- Succès -->
<div
v-if="success"
class="rounded-xl p-4 text-sm"
style="background: var(--nav-surface); color: var(--nav-text);"
>
<strong>Merci !</strong>
{{ successMessage }}
</div>
<!-- Formulaire -->
<form v-else @submit.prevent="submit" class="space-y-4" novalidate>
<!-- Commentaire -->
<div>
<label
for="comment-contenu"
class="block text-sm font-medium mb-1"
style="color: var(--nav-text);"
>
Commentaire <span aria-hidden="true">*</span>
</label>
<textarea
id="comment-contenu"
v-model="form.contenu"
required
rows="4"
minlength="10"
maxlength="500"
placeholder="Partage ton expérience avec cette organisation…"
class="w-full px-3 py-2 rounded-lg text-sm resize-none focus:outline-none focus:ring-2"
style="background: var(--nav-surface); color: var(--nav-text); border: 1px solid rgba(26,34,56,0.2); focus-ring-color: var(--nav-accent);"
:class="{ 'border-red-400': errors.contenu }"
/>
<div class="flex justify-between mt-1">
<span v-if="errors.contenu" class="text-xs text-red-500">{{ errors.contenu }}</span>
<span class="text-xs ml-auto" style="color: var(--nav-text-muted);">{{ form.contenu.length }}/500</span>
</div>
</div>
<!-- Pseudo (optionnel) -->
<div>
<label
for="comment-pseudo"
class="block text-sm font-medium mb-1"
style="color: var(--nav-text);"
>Pseudo <span class="font-normal" style="color: var(--nav-text-muted);">(optionnel)</span></label>
<input
id="comment-pseudo"
v-model="form.auteur_pseudo"
type="text"
maxlength="80"
placeholder="Marie A."
class="w-full px-3 py-2 rounded-lg text-sm focus:outline-none focus:ring-2"
style="background: var(--nav-surface); color: var(--nav-text); border: 1px solid rgba(26,34,56,0.2);"
/>
</div>
<!-- Note modération -->
<p class="text-xs" style="color: var(--nav-text-muted);">
Vos commentaires sont filtrés par une IA avant publication.
Les critiques professionnelles factuelles sont les bienvenues.
</p>
<!-- Erreur serveur -->
<p v-if="serverError" class="text-xs text-red-500">{{ serverError }}</p>
<!-- Bouton -->
<button
type="submit"
:disabled="submitting"
class="inline-flex items-center gap-2 px-5 py-2.5 rounded-lg text-sm font-medium transition-colors disabled:opacity-50"
style="background: var(--nav-primary); color: var(--nav-text-on-primary);"
@mouseenter="(e: MouseEvent) => { if (!submitting) (e.currentTarget as HTMLElement).style.background = 'rgba(26,34,56,0.75)' }"
@mouseleave="(e: MouseEvent) => { if (!submitting) (e.currentTarget as HTMLElement).style.background = 'var(--nav-primary)' }"
>
<svg v-if="submitting" class="animate-spin" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path d="M21 12a9 9 0 1 1-6.219-8.56"/>
</svg>
{{ submitting ? 'Envoi…' : 'Envoyer' }}
</button>
</form>
</section>
</template>
<script setup lang="ts">
const props = defineProps<{ orgId: number }>()
const emit = defineEmits<{ submitted: [] }>()
const form = reactive({
contenu: '',
auteur_pseudo: '',
})
const submitting = ref(false)
const success = ref(false)
const successMessage = ref('')
const serverError = ref('')
const errors = reactive({ contenu: '' })
function validate(): boolean {
errors.contenu = ''
const c = form.contenu.trim()
if (!c) { errors.contenu = 'Le commentaire est requis.'; return false }
if (c.length < 10) { errors.contenu = 'Minimum 10 caractères.'; return false }
if (c.length > 500) { errors.contenu = 'Maximum 500 caractères.'; return false }
return true
}
async function submit() {
serverError.value = ''
if (!validate()) return
submitting.value = true
try {
const res = await $fetch<{ ok: boolean; status: string; message: string }>('/api/comment', {
method: 'POST',
body: {
orga_id: props.orgId,
contenu: form.contenu.trim(),
auteur_pseudo: form.auteur_pseudo.trim() || undefined,
},
})
success.value = true
successMessage.value = res.message || 'Commentaire reçu.'
emit('submitted')
} catch (err: any) {
const status = err?.response?.status
if (status === 429) {
serverError.value = 'Trop de commentaires aujourd\'hui. Réessaie demain.'
} else {
serverError.value = 'Erreur lors de l\'envoi. Réessaie dans un moment.'
}
} finally {
submitting.value = false
}
}
</script>

View File

@@ -0,0 +1,100 @@
<template>
<section class="mb-6">
<h2
class="text-lg font-semibold mb-4"
style="color: var(--nav-text);"
>
Commentaires
<span
v-if="comments.length"
class="ml-2 text-sm font-normal"
style="color: var(--nav-text-muted);"
>({{ comments.length }})</span>
</h2>
<!-- Texte d'intro -->
<p class="text-sm italic mb-4" style="color: var(--nav-text-muted);">
Les commentaires servent à confirmer ou attester de la fiabilité des services des organismes référencés.
</p>
<!-- Chargement -->
<div v-if="pending" class="text-sm py-4" style="color: var(--nav-text-muted);">
Chargement des commentaires…
</div>
<!-- Liste -->
<div v-else-if="comments.length" class="space-y-3">
<article
v-for="comment in comments"
:key="comment.Id"
class="rounded-xl p-4"
style="background: var(--nav-surface); border: 1px solid var(--nav-bg-alt);"
>
<!-- Méta auteur + date -->
<div class="flex items-center gap-2 mb-2">
<!-- Avatar initiale -->
<div
class="w-7 h-7 rounded-full flex items-center justify-center text-xs font-semibold shrink-0"
style="background: var(--nav-primary); color: var(--nav-text-on-primary);"
>{{ initiale(comment.auteur_pseudo) }}</div>
<span class="text-sm font-medium" style="color: var(--nav-text);">
{{ comment.auteur_pseudo || 'Anonyme' }}
</span>
<span v-if="comment.submitted_at" class="text-xs ml-auto" style="color: var(--nav-text-muted);">
{{ formatDate(comment.submitted_at) }}
</span>
</div>
<!-- Contenu -->
<p class="text-sm leading-relaxed" style="color: var(--nav-text);">{{ comment.contenu }}</p>
</article>
</div>
<!-- Vide -->
<p v-else class="text-sm italic py-2" style="color: var(--nav-text-muted);">
Aucun commentaire pour le moment. Partage ton expérience ci-dessous.
</p>
</section>
</template>
<script setup lang="ts">
interface Comment {
Id: number
auteur_pseudo?: string
contenu: string
orga_id: number
published?: boolean
submitted_at?: string
}
const props = defineProps<{
orgId: number
refresh?: number // incrémenter pour forcer un rechargement
}>()
const { data, pending, refresh } = await useFetch<{ list: Comment[] }>(
`/api/comment/${props.orgId}`,
{ key: `comments-${props.orgId}` }
)
const comments = computed<Comment[]>(() => data.value?.list ?? [])
// Rechargement si le parent incrément refresh
watch(() => props.refresh, () => {
refresh()
})
// ── Helpers ─────────────────────────────────────────────────────────────
function initiale(pseudo?: string): string {
if (!pseudo) return '?'
return pseudo.trim().charAt(0).toUpperCase()
}
function formatDate(iso?: string): string {
if (!iso) return ''
const d = new Date(iso)
return d.toLocaleDateString('fr-FR', { day: 'numeric', month: 'long', year: 'numeric' })
}
</script>

View File

@@ -0,0 +1,63 @@
<template>
<div class="space-y-1.5">
<p class="text-xs font-bold uppercase tracking-widest" style="color: var(--nav-text-muted);">Échelle</p>
<!-- Inline sur 1 ligne même pattern que FonctionFilter -->
<div class="flex flex-wrap gap-x-4 gap-y-1.5">
<label
v-for="option in ECHELLES"
:key="option"
class="flex items-center gap-1.5 cursor-pointer select-none transition-opacity"
>
<!-- Case carrée -->
<span
class="flex items-center justify-center shrink-0 transition-all"
style="width: 18px; height: 18px; border: 1.5px solid; border-radius: 3px;"
:style="isSelected(option)
? 'background: var(--nav-primary); border-color: var(--nav-primary); color: #ffffff;'
: 'background: var(--nav-bg-alt); border-color: rgba(26,34,56,0.25); color: transparent;'"
>
<svg v-if="isSelected(option)" width="11" height="11" viewBox="0 0 12 12" fill="none">
<polyline points="2,6 5,9 10,3" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</span>
<!-- Label -->
<span
class="text-sm leading-tight"
:style="isSelected(option) ? 'color: var(--nav-text); font-weight: 600;' : 'color: var(--nav-text);'"
>{{ option }}</span>
<!-- Input réel (masqué) -->
<input
type="checkbox"
class="sr-only"
:checked="isSelected(option)"
@change="toggle(option)"
/>
</label>
</div>
</div>
</template>
<script setup lang="ts">
const ECHELLES = ['National', 'Régional', 'Local'] as const
const props = defineProps<{
modelValue: string[]
counts: Record<string, number>
}>()
const emit = defineEmits<{
'update:modelValue': [value: string[]]
}>()
function isSelected(option: string): boolean {
return props.modelValue.includes(option)
}
function toggle(option: string) {
if (isSelected(option)) {
emit('update:modelValue', props.modelValue.filter(v => v !== option))
} else {
emit('update:modelValue', [...props.modelValue, option])
}
}
</script>

375
components/FicheDetail.vue Normal file
View File

@@ -0,0 +1,375 @@
<template>
<div>
<!-- En-tête -->
<div
class="rounded-2xl overflow-hidden mb-6"
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"
style="color: var(--nav-text);"
>{{ org.nom }}</h1>
<a
v-if="org.url"
:href="org.url"
target="_blank"
rel="noopener noreferrer"
class="shrink-0 inline-flex items-center gap-1.5 text-sm font-medium px-3 py-1.5 rounded-lg transition-colors"
style="color: var(--nav-text); background: var(--nav-bg-alt);"
@mouseenter="(e: MouseEvent) => (e.target as HTMLElement).style.background = 'var(--nav-accent)'"
@mouseleave="(e: MouseEvent) => (e.target as HTMLElement).style.background = 'var(--nav-bg-alt)'"
>
<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="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>
Visiter le site
</a>
</div>
<!-- Méta : échelle + ville -->
<div class="flex flex-wrap items-center gap-2 text-sm mb-3" style="color: var(--nav-text-muted);">
<span
v-if="org.echelle"
class="px-2 py-0.5 rounded-full text-xs font-medium"
style="background: var(--nav-primary); color: var(--nav-text-on-primary);"
>{{ org.echelle }}</span>
<span v-if="org.territoire && org.territoire !== 'Métropole'" class="text-xs" style="color: var(--nav-text-muted);">{{ org.territoire }}</span>
<span v-if="org.localisation_ville" style="color: var(--nav-text-muted);">{{ org.localisation_ville }}</span>
</div>
<!-- Tags fonction -->
<div v-if="fonctionTags.length" class="flex flex-wrap gap-1.5">
<span
v-for="tag in fonctionTags"
:key="tag"
class="px-2 py-0.5 rounded-full text-xs font-medium"
style="background: var(--nav-bg-alt); color: var(--nav-text);"
>{{ tag }}</span>
</div>
</div>
<!-- Corps : description + mini-carte -->
<div class="p-6 flex flex-col lg:flex-row gap-6">
<!-- Descriptions -->
<div class="flex-1 min-w-0">
<!-- Description soumise par le contributeur -->
<div v-if="descriptionUser" class="mb-4">
<p class="text-sm leading-relaxed" style="color: var(--nav-text-muted);">Description communauté</p>
<p class="mt-1 leading-relaxed" style="color: var(--nav-text);">{{ descriptionUser }}</p>
</div>
<!-- Séparateur si les deux descriptions existent -->
<hr v-if="descriptionUser && descriptionEnrichie" style="border-color: var(--nav-bg-alt);" class="my-4" />
<!-- Description enrichie IA -->
<div v-if="descriptionEnrichie" class="mb-4">
<p class="text-sm leading-relaxed flex items-center gap-1.5" style="color: var(--nav-text-muted);">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path d="M12 2L2 7l10 5 10-5-10-5z"/>
<path d="M2 17l10 5 10-5"/>
<path d="M2 12l10 5 10-5"/>
</svg>
Synthèse IA
</p>
<p class="mt-1 leading-relaxed" style="color: var(--nav-text);">{{ descriptionEnrichie }}</p>
</div>
<!-- Fallback description V1 -->
<div v-if="!descriptionUser && !descriptionEnrichie && org.description" class="mb-4">
<p class="leading-relaxed" style="color: var(--nav-text);">{{ org.description }}</p>
</div>
<!-- Points clés -->
<div v-if="pointsCles.length" class="mt-4">
<p class="text-sm font-medium mb-2" style="color: var(--nav-text-muted);">Points clés</p>
<ul class="space-y-1">
<li
v-for="(point, i) in pointsCles"
:key="i"
class="flex items-start gap-2 text-sm leading-relaxed"
style="color: var(--nav-text);"
>
<span class="mt-1 shrink-0 w-1.5 h-1.5 rounded-full" style="background: var(--nav-accent);"></span>
{{ point }}
</li>
</ul>
</div>
</div>
<!-- Mini-carte Leaflet -->
<div
v-if="hasCoords"
class="shrink-0 lg:w-56 xl:w-64 rounded-xl overflow-hidden"
style="height: 180px; border: 1px solid var(--nav-bg-alt);"
>
<ClientOnly>
<div ref="mapContainer" class="w-full h-full"></div>
<template #fallback>
<div
class="w-full h-full flex items-center justify-center text-xs"
style="background: var(--nav-bg-alt); color: var(--nav-text-muted);"
>Carte</div>
</template>
</ClientOnly>
</div>
</div>
</div>
<!-- Signalement -->
<div class="mb-2">
<button
type="button"
class="report-toggle"
@click="reportOpen = !reportOpen"
:aria-expanded="reportOpen"
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/>
<line x1="12" y1="9" x2="12" y2="13"/>
<line x1="12" y1="17" x2="12.01" y2="17"/>
</svg>
Signaler une erreur ou proposer une modification
</button>
<Transition name="report-form">
<div v-if="reportOpen" class="report-panel">
<p class="text-xs mb-3" style="color: var(--nav-text-muted);">
Tes suggestions seront transmises à l'équipe AEP par email.
</p>
<div class="flex flex-col gap-3">
<textarea
v-model="reportMessage"
maxlength="500"
rows="3"
placeholder="Que proposes-tu de modifier ou signaler ? (max 500 caractères)"
class="report-input report-textarea"
:disabled="reportLoading"
/>
<div class="flex items-end gap-3">
<input
v-model="reportEmail"
type="email"
placeholder="Ton email (obligatoire)"
class="report-input flex-1"
:disabled="reportLoading"
/>
<button
type="button"
class="report-submit"
:disabled="reportLoading || !reportMessage.trim() || !reportEmail.trim()"
@click="submitReport"
>
{{ reportLoading ? 'Envoi' : 'Envoyer' }}
</button>
</div>
<p v-if="reportError" class="text-xs" style="color: #e53e3e;">{{ reportError }}</p>
<p v-if="reportSuccess" class="text-xs" style="color: #38a169;">{{ reportSuccess }}</p>
<p class="text-xs" style="color: var(--nav-text-muted); opacity: 0.6;">
{{ reportMessage.length }}/500
</p>
</div>
</div>
</Transition>
</div>
</div>
</template>
<script setup lang="ts">
import type { Org } from '~/types/org'
const props = defineProps<{ org: Org }>()
// ── Champs ──────────────────────────────────────────────────────────────
const descriptionUser = computed(() =>
props.org.description_user?.trim() || null
)
const descriptionEnrichie = computed(() =>
props.org.description_enrichie?.trim() || null
)
const fonctionTags = computed<string[]>(() => {
const raw = props.org.tags_fonction
if (!raw) return []
return raw.split(',').map((t) => t.trim()).filter(Boolean)
})
const pointsCles = computed<string[]>(() => {
const raw = props.org.points_cles
if (!raw) return []
try {
const parsed = JSON.parse(raw)
if (Array.isArray(parsed)) return parsed.filter(Boolean)
} catch {
// Format texte brut — traiter chaque ligne
return raw.split('\n').map((l) => l.trim().replace(/^[-•*]\s*/, '')).filter(Boolean)
}
return []
})
const hasCoords = computed(
() => !!props.org.latitude && !!props.org.longitude
)
// ── Signalement ────────────────────────────────────────────────────────
const reportOpen = ref(false)
const reportMessage = ref('')
const reportEmail = ref('')
const reportLoading = ref(false)
const reportError = ref('')
const reportSuccess = ref('')
async function submitReport() {
reportError.value = ''
reportSuccess.value = ''
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
if (!reportMessage.value.trim() || reportMessage.value.length < 5) {
reportError.value = 'Le message doit contenir au moins 5 caractères.'
return
}
if (!emailRegex.test(reportEmail.value)) {
reportError.value = 'Adresse email invalide.'
return
}
reportLoading.value = true
try {
const res = await $fetch<{ ok: boolean; message: string }>('/api/report', {
method: 'POST',
body: { fiche_id: props.org.Id, message: reportMessage.value, email: reportEmail.value },
})
if (res.ok) {
reportSuccess.value = res.message
reportMessage.value = ''
reportEmail.value = ''
setTimeout(() => { reportOpen.value = false; reportSuccess.value = '' }, 3000)
}
} catch (e: any) {
reportError.value = e?.data?.statusMessage || 'Erreur lors de l\'envoi.'
} finally {
reportLoading.value = false
}
}
// ── Mini-carte Leaflet ────────────────────────────────────────────────
const mapContainer = ref<HTMLElement | null>(null)
onMounted(async () => {
if (!hasCoords.value || !mapContainer.value) return
const L = (await import('leaflet')).default
const lat = props.org.latitude as number
const lng = props.org.longitude as number
const map = L.map(mapContainer.value, {
center: [lat, lng],
zoom: 10,
zoomControl: false,
dragging: false,
touchZoom: false,
doubleClickZoom: false,
scrollWheelZoom: false,
keyboard: false,
attributionControl: false,
})
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 18,
}).addTo(map)
// Pin personnalisé
const icon = L.divIcon({
className: '',
html: `<div style="
width:14px; height:14px; border-radius:50%;
background: rgba(26,34,56,0.6);
border: 2px solid white;
box-shadow: 0 1px 4px rgba(26,34,56,0.4);
"></div>`,
iconSize: [14, 14],
iconAnchor: [7, 7],
})
L.marker([lat, lng], { icon }).addTo(map)
})
</script>
<style scoped>
/* ── Signalement ─────────────────────────────────────────────────────── */
.report-toggle {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 0.78rem;
color: var(--nav-text-muted);
background: none;
border: none;
cursor: pointer;
padding: 6px 0;
opacity: 0.7;
transition: opacity 0.15s;
font-family: inherit;
}
.report-toggle:hover { opacity: 1; }
.report-panel {
margin-top: 10px;
padding: 14px 16px;
border-radius: 10px;
border: 1px solid var(--nav-bg-alt);
background: var(--nav-bg);
}
.report-input {
width: 100%;
padding: 8px 12px;
border-radius: 8px;
border: 1px solid var(--nav-bg-alt);
background: var(--nav-surface);
color: var(--nav-text);
font-size: 0.82rem;
font-family: inherit;
outline: none;
transition: border-color 0.15s;
box-sizing: border-box;
}
.report-input:focus { border-color: var(--nav-primary-solid); }
.report-input:disabled { opacity: 0.6; cursor: not-allowed; }
.report-textarea {
resize: vertical;
min-height: 72px;
}
.report-submit {
padding: 8px 16px;
border-radius: 8px;
border: none;
background: var(--nav-primary-solid);
color: var(--nav-text-on-primary);
font-size: 0.82rem;
font-weight: 600;
font-family: inherit;
cursor: pointer;
white-space: nowrap;
transition: opacity 0.15s;
}
.report-submit:hover:not(:disabled) { opacity: 0.85; }
.report-submit:disabled { opacity: 0.45; cursor: not-allowed; }
/* Transition form */
.report-form-enter-active, .report-form-leave-active { transition: opacity 0.2s ease, max-height 0.2s ease; overflow: hidden; max-height: 300px; }
.report-form-enter-from, .report-form-leave-to { opacity: 0; max-height: 0; }
</style>

164
components/FicheModal.vue Normal file
View File

@@ -0,0 +1,164 @@
<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="fixed z-[1501] top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 flex flex-col"
style="
width: min(768px, 92vw);
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="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>
/* 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>

View File

@@ -0,0 +1,67 @@
<template>
<div class="space-y-1.5">
<p class="text-xs font-bold uppercase tracking-widest" style="color: var(--nav-text-muted);">Fonction</p>
<div class="space-y-1">
<button
v-for="fn in FONCTIONS"
:key="fn"
@click="toggle(fn)"
:aria-pressed="modelValue.includes(fn)"
class="flex items-center gap-2.5 w-full rounded px-1 py-0.5 transition-all text-left hover:opacity-80"
:style="modelValue.includes(fn) ? 'background: rgba(26,34,56,0.06);' : ''"
>
<!-- Case : affiche le rang de priorité si actif, sinon le nombre d'orgs -->
<span
class="flex items-center justify-center shrink-0 text-xs font-bold transition-all"
style="width: 24px; height: 24px; border: 1.5px solid; border-radius: 4px;"
:style="modelValue.includes(fn)
? 'background: var(--nav-primary); border-color: var(--nav-primary); color: var(--nav-text-on-primary);'
: 'background: var(--nav-bg-alt); border-color: var(--nav-bg-alt); color: var(--nav-text-muted);'"
>
{{ modelValue.includes(fn) ? (modelValue.indexOf(fn) + 1) : (counts[fn] ?? 0) }}
</span>
<!-- Label -->
<span
class="text-sm leading-tight"
:style="modelValue.includes(fn) ? 'color: var(--nav-text); font-weight: 600;' : 'color: var(--nav-text);'"
>{{ fn }}</span>
</button>
</div>
<p v-if="modelValue.length" class="text-xs pt-0.5" style="color: var(--nav-text-muted);">
{{ modelValue.length }} actif{{ modelValue.length > 1 ? 's' : '' }}
<button @click="emit('update:modelValue', [])" class="ml-2 underline hover:opacity-70">Effacer</button>
</p>
</div>
</template>
<script setup lang="ts">
const FONCTIONS = [
'Juridique',
'Technique',
'Économique',
'Administratif',
'Chantier',
'Comptabilité',
'Développement',
'Formation',
"Gestion d'agence",
'Santé mentale',
] as const
const props = defineProps<{
modelValue: string[]
counts: Record<string, number>
}>()
const emit = defineEmits<{
'update:modelValue': [value: string[]]
}>()
function toggle(fn: string) {
if (props.modelValue.includes(fn)) {
emit('update:modelValue', props.modelValue.filter(f => f !== fn))
} else {
emit('update:modelValue', [...props.modelValue, fn])
}
}
</script>

218
components/MobileSheet.vue Normal file
View File

@@ -0,0 +1,218 @@
<!--
MobileSheet Bottom sheet swipable 3 états (collapsed / half / full)
Pattern Google Maps mobile
États :
- collapsed : juste la poignée + compteur (~56px)
- half : ~50dvh (état initial)
- full : ~92dvh (plein écran)
Déclenché par touch/drag sur la poignée
-->
<template>
<div
class="mobile-sheet"
:class="`mobile-sheet--${state}`"
:style="{ transform: `translateY(${dragOffset}px)` }"
>
<!-- Poignée drag -->
<div
class="sheet-handle-area"
@touchstart.passive="onTouchStart"
@touchmove.passive="onTouchMove"
@touchend="onTouchEnd"
>
<div class="sheet-handle-bar" />
</div>
<!-- Compteur compact (toujours visible) -->
<div class="sheet-header" @click="onHeaderClick">
<span class="sheet-counter">
<span v-if="pending">Chargement</span>
<span v-else>{{ resultCount }} fiche{{ resultCount > 1 ? 's' : '' }}</span>
</span>
<svg
width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2.5" stroke-linecap="round" aria-hidden="true"
class="sheet-chevron"
:class="{ 'sheet-chevron--up': state !== 'collapsed' }"
style="color: var(--nav-text-muted); flex-shrink: 0;"
>
<polyline points="18 15 12 9 6 15"/>
</svg>
</div>
<!-- Contenu scrollable (caché si collapsed) -->
<div
v-show="state !== 'collapsed'"
ref="contentEl"
class="sheet-content"
>
<slot />
</div>
</div>
</template>
<script setup lang="ts">
type SheetState = 'collapsed' | 'half' | 'full'
const props = defineProps<{
resultCount: number
pending?: boolean
}>()
const emit = defineEmits<{
'state-change': [state: SheetState]
}>()
const state = ref<SheetState>('half')
const dragOffset = ref(0)
const contentEl = ref<HTMLElement | null>(null)
let touchStartY = 0
let touchCurrentY = 0
let isDragging = false
// Cycle états au clic header
function onHeaderClick() {
if (state.value === 'collapsed') {
setSheetState('half')
} else if (state.value === 'half') {
setSheetState('full')
} else {
setSheetState('collapsed')
}
}
function setSheetState(next: SheetState) {
state.value = next
dragOffset.value = 0
emit('state-change', next)
}
// Touch handlers
function onTouchStart(e: TouchEvent) {
touchStartY = e.touches[0].clientY
touchCurrentY = touchStartY
isDragging = true
dragOffset.value = 0
}
function onTouchMove(e: TouchEvent) {
if (!isDragging) return
touchCurrentY = e.touches[0].clientY
const delta = touchCurrentY - touchStartY
// Limiter le drag visuellement (résistance)
dragOffset.value = delta * 0.6
}
function onTouchEnd() {
if (!isDragging) return
isDragging = false
const delta = touchCurrentY - touchStartY
const threshold = 60 // px minimum pour changer d'état
if (delta > threshold) {
// Swipe vers le bas → état inférieur
if (state.value === 'full') setSheetState('half')
else if (state.value === 'half') setSheetState('collapsed')
else setSheetState('collapsed')
} else if (delta < -threshold) {
// Swipe vers le haut → état supérieur
if (state.value === 'collapsed') setSheetState('half')
else if (state.value === 'half') setSheetState('full')
else setSheetState('full')
} else {
// Snap back
dragOffset.value = 0
}
// Transition smooth au relâchement
dragOffset.value = 0
}
</script>
<style scoped>
.mobile-sheet {
position: fixed;
inset-x: 0;
bottom: 0;
z-index: 500;
background: var(--nav-surface);
border-radius: 16px 16px 0 0;
box-shadow: 0 -4px 24px rgba(26, 34, 56, 0.14);
display: flex;
flex-direction: column;
transition: height 0.3s cubic-bezier(0.32, 0.72, 0, 1), transform 0.05s linear;
will-change: height, transform;
overflow: hidden;
}
/* ── États hauteur ───────────────────────────────────────────────────── */
.mobile-sheet--collapsed {
height: 56px;
}
.mobile-sheet--half {
height: 50dvh;
min-height: 200px;
}
.mobile-sheet--full {
height: 92dvh;
}
/* ── Poignée ─────────────────────────────────────────────────────────── */
.sheet-handle-area {
display: flex;
justify-content: center;
padding: 10px 0 4px;
cursor: grab;
flex-shrink: 0;
}
.sheet-handle-area:active {
cursor: grabbing;
}
.sheet-handle-bar {
width: 36px;
height: 4px;
border-radius: 2px;
background: var(--nav-bg-alt);
}
/* ── Header compteur ─────────────────────────────────────────────────── */
.sheet-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px 8px;
cursor: pointer;
flex-shrink: 0;
gap: 8px;
}
.sheet-counter {
font-size: 0.8rem;
font-weight: 600;
color: var(--nav-text-muted);
}
.sheet-chevron {
transition: transform 0.2s ease;
}
.sheet-chevron--up {
transform: rotate(180deg);
}
/* ── Contenu scrollable ──────────────────────────────────────────────── */
.sheet-content {
flex: 1;
overflow-y: auto;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
}
</style>

241
components/NavMap.vue Normal file
View File

@@ -0,0 +1,241 @@
<template>
<div class="relative w-full h-full">
<div ref="mapContainer" class="w-full h-full rounded-none" />
</div>
</template>
<script setup lang="ts">
import type { Map, Marker, DivIcon } from 'leaflet'
interface Org {
Id: number
nom: string
latitude?: number | null
longitude?: number | null
echelle?: string
tags_fonction?: string
territoire?: string
localisation_ville?: string
url?: string
prioritaire?: boolean
}
const props = defineProps<{
orgs: Org[]
selectedId?: number | null
}>()
const emit = defineEmits<{
'select-org': [id: number]
}>()
const mapContainer = ref<HTMLElement | null>(null)
let mapInstance: Map | null = null
let clusterGroup: any = null
const markers = new Map<number, Marker>()
let tileLayerInstance: any = null
// Créer une DivIcon pour les pins personnalisés
function createPinIcon(isPrioritaire: boolean, isSelected = false): DivIcon {
const L = (window as any).L
const bg = isPrioritaire ? '#f5b342' : 'rgba(26, 34, 56, 0.6)'
const border = isPrioritaire ? '#1a2238' : '#ffffff'
const size = isSelected ? 18 : 14
const shadow = isSelected ? '0 0 0 4px rgba(245,179,66,0.5)' : 'none'
return L.divIcon({
className: '',
html: `<div style="
width: ${size}px;
height: ${size}px;
border-radius: 50%;
background: ${bg};
border: 2px solid ${border};
box-shadow: ${shadow};
transition: all 0.2s;
"></div>`,
iconSize: [size, size],
iconAnchor: [size / 2, size / 2],
popupAnchor: [0, -(size / 2 + 4)],
})
}
async function initMap() {
if (!mapContainer.value) return
const Lmod = await import('leaflet')
const L: any = (Lmod as any).default || Lmod
await import('leaflet/dist/leaflet.css')
// @ts-ignore
await import('leaflet.markercluster/dist/MarkerCluster.css')
// @ts-ignore
await import('leaflet.markercluster/dist/MarkerCluster.Default.css')
// Installer L globalement AVANT le plugin (markercluster lit window.L au load)
;(window as any).L = L
// @ts-ignore — étend L.MarkerClusterGroup en side effect
await import('leaflet.markercluster')
const MarkerClusterGroup = L.MarkerClusterGroup
mapInstance = L.map(mapContainer.value, {
center: [46.6, 2.3],
zoom: 6,
zoomControl: true,
attributionControl: true,
maxBounds: [[41.0, -5.5], [51.5, 10.0]],
maxBoundsViscosity: 1.0,
minZoom: 5,
maxZoom: 18,
})
// Fond de carte CartoDB Positron (light ou dark selon theme)
const isDark = typeof document !== 'undefined' && document.documentElement.classList.contains('dark')
const tileUrl = isDark
? 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'
: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png'
tileLayerInstance = L.tileLayer(tileUrl, {
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors © <a href="https://carto.com/attributions">CARTO</a>',
maxZoom: 19,
})
tileLayerInstance.addTo(mapInstance!)
// Cluster dès 15+ pins
clusterGroup = new MarkerClusterGroup({
disableClusteringAtZoom: 14,
maxClusterRadius: 50,
showCoverageOnHover: false,
iconCreateFunction: (cluster: any) => {
const count = cluster.getChildCount()
return L.divIcon({
html: `<div style="
width: 36px; height: 36px; border-radius: 50%;
background: var(--nav-primary);
color: var(--nav-text-on-primary);
display: flex; align-items: center; justify-content: center;
font-weight: 700; font-size: 13px;
border: 2px solid white;
font-family: var(--nav-font);
">${count}</div>`,
className: '',
iconSize: [36, 36],
iconAnchor: [18, 18],
})
},
})
mapInstance.addLayer(clusterGroup)
updateMarkers(L)
}
function updateMarkers(L?: any) {
if (!mapInstance || !clusterGroup) return
const leaflet = L || (window as any).L
if (!leaflet) return
// Clear existing
clusterGroup.clearLayers()
markers.clear()
const orgsWithCoords = props.orgs.filter(
(o) => o.latitude != null && o.longitude != null
)
orgsWithCoords.forEach((org) => {
const isSelected = org.Id === props.selectedId
const icon = createPinIcon(!!org.prioritaire, isSelected)
const marker = leaflet.marker([org.latitude!, org.longitude!], { icon })
const fonctions = org.tags_fonction
? org.tags_fonction.split(',').map((f: string) => f.trim()).filter(Boolean).slice(0, 2).join(', ')
: ''
marker.bindPopup(`
<div style="font-family: var(--nav-font); min-width: 180px; padding: 4px 0;">
<div style="font-weight: 700; color: var(--nav-text); margin-bottom: 4px;">${org.nom}</div>
${org.echelle ? `<div style="font-size: 11px; color: var(--nav-text-muted);">${org.echelle}${org.localisation_ville ? ' · ' + org.localisation_ville : ''}</div>` : ''}
${fonctions ? `<div style="font-size: 11px; color: var(--nav-text-muted); margin-top: 2px;">${fonctions}</div>` : ''}
<a href="/fiche/${org.Id}" style="
display: inline-block; margin-top: 8px; font-size: 12px;
color: var(--nav-primary-solid); text-decoration: underline;
">Voir la fiche →</a>
</div>
`, { maxWidth: 240 })
marker.on('click', () => emit('select-org', org.Id))
markers.set(org.Id, marker)
clusterGroup.addLayer(marker)
})
}
// Réagir aux changements de filtres (liste d'orgs)
watch(
() => props.orgs,
() => updateMarkers(),
{ deep: false }
)
// Réagir à la sélection
watch(
() => props.selectedId,
(newId, oldId) => {
if (!mapInstance) return
const leaflet = (window as any).L
if (!leaflet) return
// Remettre l'ancien marker à la normale
if (oldId != null) {
const oldMarker = markers.get(oldId)
const oldOrg = props.orgs.find(o => o.Id === oldId)
if (oldMarker && oldOrg) {
oldMarker.setIcon(createPinIcon(!!oldOrg.prioritaire, false))
}
}
// Mettre en avant le nouveau marker
if (newId != null) {
const newMarker = markers.get(newId)
const newOrg = props.orgs.find(o => o.Id === newId)
if (newMarker && newOrg) {
newMarker.setIcon(createPinIcon(!!newOrg.prioritaire, true))
// Centrer si visible
const latLng = newMarker.getLatLng()
mapInstance.panTo(latLng, { animate: true })
}
}
}
)
// Watcher dark mode — switch tuile CartoDB light_all ↔ dark_all
function updateTileTheme(dark: boolean) {
if (!mapInstance || !tileLayerInstance) return
const L = (window as any).L
if (!L) return
const url = dark
? 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'
: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png'
tileLayerInstance.setUrl(url)
}
let themeObserver: MutationObserver | null = null
onMounted(() => {
initMap()
// Observer les changements de classe dark sur <html>
themeObserver = new MutationObserver(() => {
const dark = document.documentElement.classList.contains('dark')
updateTileTheme(dark)
})
themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] })
})
onUnmounted(() => {
themeObserver?.disconnect()
if (mapInstance) {
mapInstance.remove()
mapInstance = null
}
})
</script>

257
components/NavSidebar.vue Normal file
View File

@@ -0,0 +1,257 @@
<template>
<aside
class="flex flex-col h-full overflow-hidden"
style="background: var(--nav-surface); border-right: 1px solid var(--nav-bg-alt);"
>
<!-- BARRE DE RECHERCHE (tout en haut) -->
<div
class="shrink-0 px-4 pt-4 pb-3 border-b"
style="border-color: var(--nav-bg-alt);"
>
<label class="sidebar-search-label" aria-label="Rechercher une organisation">
<svg
width="15" height="15" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round"
aria-hidden="true"
class="sidebar-search-icon"
>
<circle cx="11" cy="11" r="8"/>
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
</svg>
<input
ref="searchInputEl"
:value="search"
type="search"
placeholder="Rechercher une organisation…"
class="sidebar-search-input"
autocomplete="off"
@input="emit('update:search', ($event.target as HTMLInputElement).value)"
/>
<button
v-if="search"
type="button"
class="sidebar-search-clear"
aria-label="Effacer la recherche"
@click.stop="emit('update:search', '')"
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</label>
</div>
<!-- FILTRES (haut, compact) -->
<div
class="shrink-0 px-4 pt-3 pb-3 space-y-4 border-b"
style="border-color: var(--nav-bg-alt);"
>
<!-- Échelle (checkbox compactes) -->
<EchelleFilter
:modelValue="echelle"
:counts="echelleCount"
@update:modelValue="emit('update:echelle', $event)"
/>
<!-- Fonctions (checkbox compactes) -->
<FonctionFilter
:modelValue="fonctions"
:counts="fonctionCount"
@update:modelValue="emit('update:fonctions', $event)"
/>
</div>
<!-- LISTE FICHES (milieu, scrollable) -->
<div class="flex-1 flex flex-col min-h-0">
<!-- Header liste -->
<div
class="shrink-0 flex items-center justify-between px-4 py-2 border-b"
style="border-color: var(--nav-bg-alt);"
>
<span class="text-xs font-bold uppercase tracking-widest" style="color: var(--nav-text-muted);">
{{ resultCount }} résultat{{ resultCount > 1 ? 's' : '' }}
</span>
<button
v-if="hasActiveFilters"
@click="emit('reset-filters')"
class="text-xs underline hover:opacity-70"
style="color: var(--nav-text-muted);"
>
Effacer les filtres
</button>
</div>
<!-- Liste scrollable -->
<div class="flex-1 overflow-y-auto px-3 py-2 space-y-1.5">
<div
v-if="pending"
class="flex items-center justify-center py-8"
style="color: var(--nav-text-muted);"
>
Chargement
</div>
<div v-else-if="orgs.length === 0" class="text-center py-8">
<p class="text-xs" style="color: var(--nav-text-muted);">Aucun résultat</p>
</div>
<!-- Card fiche compacte -->
<div
v-for="org in orgs"
:key="org.Id"
class="rounded-lg px-3 py-2 cursor-pointer transition-all"
:style="selectedId === org.Id
? 'background: var(--nav-bg-alt); border-left: 3px solid var(--nav-accent); padding-left: 9px;'
: 'background: var(--nav-bg); border-left: 3px solid transparent; padding-left: 9px;'"
@click="emit('select-org', org.Id)"
@mouseenter="emit('hover-org', org.Id)"
@mouseleave="emit('hover-org', null)"
>
<div class="flex items-start justify-between gap-1.5">
<span
class="font-semibold text-sm leading-snug"
style="color: var(--nav-text);"
>{{ org.nom }}</span>
<span
v-if="org.echelle"
class="shrink-0 px-1.5 py-0.5 rounded-full text-xs"
style="background: var(--nav-bg-alt); color: var(--nav-text-muted); margin-top: 1px;"
>{{ org.echelle }}</span>
</div>
<div v-if="orgFonctions(org).length" class="mt-1 flex flex-wrap gap-1">
<span
v-for="fn in orgFonctions(org)"
:key="fn"
class="px-1.5 py-0.5 rounded text-xs"
style="background: var(--nav-bg-alt); color: var(--nav-text-muted);"
>{{ fn }}</span>
</div>
<div v-if="org.localisation_ville" class="mt-0.5 text-xs" style="color: var(--nav-text-muted);">
{{ org.localisation_ville }}
</div>
</div>
</div>
</div>
</aside>
</template>
<script setup lang="ts">
interface Org {
Id: number
nom: string
echelle?: string
tags_fonction?: string
territoire?: string
localisation_ville?: string
latitude?: number | null
longitude?: number | null
prioritaire?: boolean
}
const props = defineProps<{
search: string
modeValue: string // 'metropole' | 'outremer'
echelle: string[]
fonctions: string[]
territoire: string | null
echelleCount: Record<string, number>
fonctionCount: Record<string, number>
territoireCount: Record<string, number>
resultCount: number
orgs: Org[] // fiches filtrées à afficher dans la liste
selectedId: number | null
hasActiveFilters: boolean
pending?: boolean
}>()
const emit = defineEmits<{
'update:search': [value: string]
'update:mode': [value: string]
'update:echelle': [value: string[]]
'update:fonctions': [value: string[]]
'update:territoire': [value: string | null]
'select-org': [id: number]
'hover-org': [id: number | null]
'reset-filters': []
}>()
const searchInputEl = ref<HTMLInputElement | null>(null)
function orgFonctions(org: Org): string[] {
return (org.tags_fonction ?? '').split(',').map(f => f.trim()).filter(Boolean).slice(0, 2)
}
</script>
<style scoped>
.sidebar-search-label {
display: flex;
align-items: center;
gap: 8px;
border: 1.5px solid var(--nav-bg-alt);
border-radius: 10px;
background: var(--nav-bg);
padding: 7px 10px;
cursor: text;
width: 100%;
box-sizing: border-box;
transition: border-color 0.2s;
}
.sidebar-search-label:focus-within {
border-color: var(--nav-primary);
background: var(--nav-surface);
}
.sidebar-search-icon {
color: var(--nav-text-muted);
flex-shrink: 0;
transition: color 0.2s;
}
.sidebar-search-label:focus-within .sidebar-search-icon {
color: var(--nav-primary-solid);
}
.sidebar-search-input {
border: none;
outline: none;
background: transparent;
color: var(--nav-text);
font-size: 13px;
width: 100%;
min-width: 0;
font-family: var(--nav-font);
}
.sidebar-search-input::placeholder {
color: var(--nav-text-muted);
}
.sidebar-search-input::-webkit-search-cancel-button {
display: none;
}
.sidebar-search-clear {
display: flex;
align-items: center;
justify-content: center;
color: var(--nav-text-muted);
flex-shrink: 0;
padding: 2px;
border-radius: 50%;
transition: color 0.15s, background 0.15s;
background: transparent;
border: none;
cursor: pointer;
}
.sidebar-search-clear:hover {
color: var(--nav-text);
background: var(--nav-bg-alt);
}
</style>

45
components/OrgCard.vue Normal file
View File

@@ -0,0 +1,45 @@
<template>
<NuxtLink
:to="`/fiche/${org.Id}`"
class="block bg-white rounded-xl shadow-sm border border-warm-200 hover:shadow-md hover:border-sage-300 transition-all duration-200 p-5"
>
<div class="flex items-start justify-between gap-3 mb-2">
<h2 class="font-semibold text-gray-900 text-base leading-snug">{{ org.nom }}</h2>
<TypeBadge v-if="org.type_org" :type="org.type_org" class="shrink-0 mt-0.5" />
</div>
<p class="text-gray-600 text-sm leading-relaxed mb-3 line-clamp-2">
{{ org.description }}
</p>
<div v-if="tags.length" class="flex flex-wrap gap-1.5">
<TagBadge
v-for="tag in tags"
:key="tag"
:tag="tag"
@click="$emit('filter-tag', tag)"
/>
</div>
</NuxtLink>
</template>
<script setup lang="ts">
const props = defineProps<{
org: {
Id: number
nom: string
type_org?: string
description?: string
tags?: string
lien?: string
}
}>()
defineEmits<{ 'filter-tag': [tag: string] }>()
const tags = computed(() =>
props.org.tags
? props.org.tags.split(',').map((t) => t.trim()).filter(Boolean)
: []
)
</script>

279
components/OutremerMap.vue Normal file
View File

@@ -0,0 +1,279 @@
<template>
<div class="outremer-accordion">
<div
v-for="dom in DOM_TOM"
:key="dom.name"
class="outremer-item"
>
<button
class="outremer-header"
@click="toggle(dom.name)"
:aria-expanded="openDom === dom.name"
>
<span class="outremer-title">{{ dom.name }}</span>
<span class="outremer-meta">
<span class="outremer-count-badge" :style="orgCounts[dom.name] === 0 ? 'opacity:0.4' : ''">
{{ orgCounts[dom.name] ?? 0 }} fiche{{ (orgCounts[dom.name] ?? 0) > 1 ? 's' : '' }}
</span>
<svg
width="14" height="14" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2.5" stroke-linecap="round"
aria-hidden="true"
class="outremer-chevron"
:class="{ 'outremer-chevron--open': openDom === dom.name }"
>
<polyline points="6 9 12 15 18 9"/>
</svg>
</span>
</button>
<div
v-show="openDom === dom.name"
class="outremer-map-container"
>
<div :ref="el => { if (el) mapRefs[dom.name] = el as HTMLElement }" class="outremer-map" />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { Map as LeafletMap, TileLayer } from 'leaflet'
interface Org {
Id: number
nom: string
latitude?: number | null
longitude?: number | null
territoire?: string
echelle?: string
tags_fonction?: string
localisation_ville?: string
prioritaire?: boolean
}
const DOM_TOM = [
{ name: 'Guadeloupe', center: [16.25, -61.58] as [number, number], zoom: 9 },
{ name: 'Martinique', center: [14.65, -61.02] as [number, number], zoom: 9 },
{ name: 'Guyane', center: [4.0, -53.0] as [number, number], zoom: 6 },
{ name: 'La Réunion', center: [-21.11, 55.53] as [number, number], zoom: 9 },
{ name: 'Mayotte', center: [-12.83, 45.16] as [number, number], zoom: 10 },
]
const props = defineProps<{
orgs: Org[]
selectedId?: number | null
}>()
const emit = defineEmits<{
'select-org': [id: number]
}>()
const mapRefs: Record<string, HTMLElement> = {}
const mapInstances: Record<string, LeafletMap> = {}
const tileLayers: Record<string, TileLayer> = {}
const openDom = ref<string | null>(null)
const orgCounts = computed<Record<string, number>>(() => {
const counts: Record<string, number> = {}
DOM_TOM.forEach(d => { counts[d.name] = 0 })
props.orgs.forEach(o => {
if (o.territoire && counts[o.territoire] !== undefined) {
counts[o.territoire]++
}
})
return counts
})
function toggle(name: string) {
openDom.value = openDom.value === name ? null : name
nextTick(() => {
if (openDom.value === name && !mapInstances[name]) {
initSingleMap(name)
} else if (openDom.value === name) {
mapInstances[name]?.invalidateSize()
}
})
}
function createPinIcon(L: any, isPrioritaire: boolean, isSelected = false) {
const bg = isPrioritaire ? '#f5b342' : 'rgba(26, 34, 56, 0.6)'
const border = isPrioritaire ? '#1a2238' : '#ffffff'
const size = isSelected ? 16 : 12
return L.divIcon({
className: '',
html: `<div style="width:${size}px;height:${size}px;border-radius:50%;background:${bg};border:2px solid ${border};"></div>`,
iconSize: [size, size],
iconAnchor: [size / 2, size / 2],
popupAnchor: [0, -(size / 2 + 4)],
})
}
function getTileUrl(dark: boolean) {
return dark
? 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'
: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png'
}
async function initSingleMap(domName: string) {
const dom = DOM_TOM.find(d => d.name === domName)
if (!dom) return
const Lmod = await import('leaflet')
const L: any = (Lmod as any).default || Lmod
await import('leaflet/dist/leaflet.css')
const isDark = typeof document !== 'undefined' && document.documentElement.classList.contains('dark')
const el = mapRefs[domName]
if (!el) return
const map = L.map(el, {
center: dom.center, zoom: dom.zoom,
zoomControl: false, attributionControl: false,
dragging: true, scrollWheelZoom: true, doubleClickZoom: true,
touchZoom: true, keyboard: false,
})
const tileLayer = L.tileLayer(getTileUrl(isDark), {
attribution: '© OpenStreetMap contributors © CARTO', maxZoom: 19,
})
tileLayer.addTo(map)
tileLayers[domName] = tileLayer as unknown as TileLayer
mapInstances[domName] = map as unknown as LeafletMap
renderPins(L, domName)
}
function updateTheme(dark: boolean) {
const url = getTileUrl(dark)
Object.values(tileLayers).forEach(tl => {
(tl as any).setUrl(url)
})
}
function renderPins(L: any, domName: string) {
const map = mapInstances[domName] as any
if (!map) return
if (map._navMarkers) {
map._navMarkers.forEach((m: any) => m.remove())
}
map._navMarkers = []
const domOrgs = props.orgs.filter(o => o.territoire === domName && o.latitude != null && o.longitude != null)
domOrgs.forEach(org => {
const icon = createPinIcon(L, !!org.prioritaire, org.Id === props.selectedId)
const marker = L.marker([org.latitude!, org.longitude!], { icon })
const fonctions = org.tags_fonction
? org.tags_fonction.split(',').map((f: string) => f.trim()).filter(Boolean).slice(0, 2).join(', ')
: ''
marker.bindPopup(`
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;min-width:160px;padding:4px 0;">
<div style="font-weight:700;color:#1a2238;margin-bottom:2px;">${org.nom}</div>
${org.echelle ? `<div style="font-size:11px;color:rgba(26,34,56,0.55);">${org.echelle}${org.localisation_ville ? ' · ' + org.localisation_ville : ''}</div>` : ''}
${fonctions ? `<div style="font-size:11px;color:rgba(26,34,56,0.55);margin-top:2px;">${fonctions}</div>` : ''}
<a href="/fiche/${org.Id}" style="display:inline-block;margin-top:8px;font-size:12px;color:#1a2238;text-decoration:underline;">Voir la fiche →</a>
</div>
`, { maxWidth: 200 })
marker.on('click', () => emit('select-org', org.Id))
marker.addTo(map)
map._navMarkers.push(marker)
})
}
watch(() => props.orgs, () => {
DOM_TOM.forEach(dom => {
if (mapInstances[dom.name]) {
import('leaflet').then(L => renderPins(L, dom.name))
}
})
}, { deep: false })
watch(() => props.selectedId, () => {
DOM_TOM.forEach(dom => {
if (mapInstances[dom.name]) {
import('leaflet').then(L => renderPins(L, dom.name))
}
})
})
let themeObserver: MutationObserver | null = null
onMounted(() => {
themeObserver = new MutationObserver(() => {
const dark = document.documentElement.classList.contains('dark')
updateTheme(dark)
})
themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] })
})
onUnmounted(() => {
themeObserver?.disconnect()
Object.values(mapInstances).forEach(m => (m as any).remove())
})
</script>
<style scoped>
.outremer-accordion {
display: flex;
flex-direction: column;
width: 100%;
}
.outremer-item {
border-bottom: 1px solid var(--nav-bg-alt);
}
.outremer-header {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 12px 16px;
background: var(--nav-surface);
border: none;
cursor: pointer;
text-align: left;
transition: background 0.15s;
}
.outremer-header:hover {
background: var(--nav-bg-alt);
}
.outremer-title {
font-size: 0.875rem;
font-weight: 600;
color: var(--nav-text);
}
.outremer-meta {
display: flex;
align-items: center;
gap: 8px;
}
.outremer-count-badge {
font-size: 0.75rem;
color: var(--nav-text-muted);
}
.outremer-chevron {
color: var(--nav-text-muted);
transition: transform 0.2s ease;
flex-shrink: 0;
}
.outremer-chevron--open {
transform: rotate(180deg);
}
.outremer-map-container {
height: 220px;
border-top: 1px solid var(--nav-bg-alt);
}
.outremer-map {
width: 100%;
height: 100%;
}
</style>

11
components/TagBadge.vue Normal file
View File

@@ -0,0 +1,11 @@
<template>
<span
class="inline-block px-2 py-0.5 rounded-full text-xs font-medium bg-sage-100 text-sage-700 border border-sage-200 cursor-pointer hover:bg-sage-200 transition-colors"
@click.prevent="$emit('click', tag)"
>{{ tag }}</span>
</template>
<script setup lang="ts">
defineProps<{ tag: string }>()
defineEmits<{ click: [tag: string] }>()
</script>

162
components/TopSearchBar.vue Normal file
View File

@@ -0,0 +1,162 @@
<template>
<!-- Barre de recherche animée : compacte par défaut, s'étend au focus -->
<div class="top-search-wrapper" :class="{ expanded: isFocused || hasValue }">
<label class="top-search-label" aria-label="Rechercher une organisation">
<!-- Icône loupe -->
<svg
class="top-search-icon"
width="16" height="16" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round"
aria-hidden="true"
>
<circle cx="11" cy="11" r="8"/>
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
</svg>
<!-- Input texte -->
<input
ref="inputEl"
v-model="localValue"
type="search"
placeholder="Rechercher une organisation…"
class="top-search-input"
autocomplete="off"
@focus="isFocused = true"
@blur="isFocused = false"
@input="onInput"
/>
<!-- Bouton clear -->
<button
v-if="hasValue"
type="button"
class="top-search-clear"
aria-label="Effacer la recherche"
@click.stop="clear"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</label>
</div>
</template>
<script setup lang="ts">
const props = defineProps<{
modelValue: string
}>()
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
const inputEl = ref<HTMLInputElement | null>(null)
const isFocused = ref(false)
const localValue = ref(props.modelValue)
watch(() => props.modelValue, (v) => { localValue.value = v })
const hasValue = computed(() => localValue.value.length > 0)
function onInput() {
emit('update:modelValue', localValue.value)
}
function clear() {
localValue.value = ''
emit('update:modelValue', '')
nextTick(() => inputEl.value?.focus())
}
</script>
<style scoped>
.top-search-wrapper {
display: flex;
align-items: center;
position: relative;
flex-shrink: 0;
}
.top-search-label {
display: flex;
align-items: center;
gap: 6px;
border: 1.5px solid var(--nav-bg-alt);
border-radius: 20px;
background: var(--nav-bg);
padding: 5px 10px;
cursor: text;
width: 44px;
overflow: hidden;
transition: width 0.25s ease, border-color 0.2s, background 0.2s;
}
.top-search-wrapper.expanded .top-search-label {
width: 280px;
border-color: var(--nav-primary);
background: var(--nav-surface);
}
@media (max-width: 640px) {
.top-search-wrapper.expanded .top-search-label {
width: calc(100vw - 80px);
}
}
.top-search-icon {
color: var(--nav-text-muted);
flex-shrink: 0;
width: 16px;
height: 16px;
transition: color 0.2s;
}
.top-search-wrapper.expanded .top-search-icon {
color: var(--nav-primary-solid);
}
.top-search-input {
border: none;
outline: none;
background: transparent;
color: var(--nav-text);
font-size: 13px;
width: 100%;
min-width: 0;
opacity: 0;
transition: opacity 0.2s;
}
.top-search-wrapper.expanded .top-search-input {
opacity: 1;
}
.top-search-input::placeholder {
color: var(--nav-text-muted);
}
/* Masquer le bouton clear natif des navigateurs */
.top-search-input::-webkit-search-cancel-button {
display: none;
}
.top-search-clear {
display: flex;
align-items: center;
justify-content: center;
color: var(--nav-text-muted);
flex-shrink: 0;
padding: 1px;
border-radius: 50%;
transition: color 0.15s, background 0.15s;
background: transparent;
border: none;
cursor: pointer;
}
.top-search-clear:hover {
color: var(--nav-text);
background: var(--nav-bg-alt);
}
</style>

21
components/TypeBadge.vue Normal file
View File

@@ -0,0 +1,21 @@
<template>
<span :class="['inline-block px-2.5 py-0.5 rounded-full text-xs font-semibold uppercase tracking-wide', colorClass]">
{{ type }}
</span>
</template>
<script setup lang="ts">
const props = defineProps<{ type: string }>()
const colors: Record<string, string> = {
association: 'bg-blue-100 text-blue-700',
syndicat: 'bg-orange-100 text-orange-700',
institution: 'bg-purple-100 text-purple-700',
reseau: 'bg-teal-100 text-teal-700',
collectif: 'bg-pink-100 text-pink-700',
ecole: 'bg-yellow-100 text-yellow-700',
media: 'bg-red-100 text-red-700',
}
const colorClass = computed(() => colors[props.type] ?? 'bg-gray-100 text-gray-700')
</script>