feat(aep): carte AEP — push Gitea 2026-04-28
This commit is contained in:
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>
|
||||
Reference in New Issue
Block a user