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

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

375
components/FicheDetail.vue Normal file
View File

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