feat(aep): carte AEP — push Gitea 2026-04-28
This commit is contained in:
587
components/BandeauBas.vue
Normal file
587
components/BandeauBas.vue
Normal 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>
|
||||
·
|
||||
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>
|
||||
382
components/ChatbotPlaceholder.vue
Normal file
382
components/ChatbotPlaceholder.vue
Normal 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
406
components/ChatbotSheet.vue
Normal 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
147
components/CommentForm.vue
Normal 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>
|
||||
100
components/CommentSection.vue
Normal file
100
components/CommentSection.vue
Normal 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>
|
||||
63
components/EchelleFilter.vue
Normal file
63
components/EchelleFilter.vue
Normal 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
375
components/FicheDetail.vue
Normal 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
164
components/FicheModal.vue
Normal 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>
|
||||
67
components/FonctionFilter.vue
Normal file
67
components/FonctionFilter.vue
Normal 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
218
components/MobileSheet.vue
Normal 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
241
components/NavMap.vue
Normal 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
257
components/NavSidebar.vue
Normal 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
45
components/OrgCard.vue
Normal 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
279
components/OutremerMap.vue
Normal 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
11
components/TagBadge.vue
Normal 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
162
components/TopSearchBar.vue
Normal 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
21
components/TypeBadge.vue
Normal 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>
|
||||
Reference in New Issue
Block a user