feat(pratiques): types, API statique, composants filtres + cartes Europe/outremer
This commit is contained in:
41
components/CritereFilter.vue
Normal file
41
components/CritereFilter.vue
Normal file
@@ -0,0 +1,41 @@
|
||||
<template>
|
||||
<div>
|
||||
<span class="text-xs font-bold uppercase tracking-wide block mb-1.5" style="color: var(--nav-text-muted);">CRITÈRES RÉGÉ</span>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<button
|
||||
v-for="critere in CRITERES"
|
||||
:key="critere.id"
|
||||
type="button"
|
||||
class="px-2 py-0.5 rounded-full text-xs transition-all"
|
||||
:style="modelValue.includes(critere.id)
|
||||
? 'background: var(--nav-primary); color: var(--nav-text-on-primary); font-weight: 600;'
|
||||
: 'background: var(--nav-bg-alt); color: var(--nav-text-muted);'"
|
||||
@click="toggle(critere.id)"
|
||||
>
|
||||
{{ critere.label }}
|
||||
<span v-if="counts && counts[critere.id] !== undefined" class="ml-1 opacity-60 text-xs">{{ counts[critere.id] }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { CRITERES } from '~/types/pratique'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: number[]
|
||||
counts?: Record<number, number>
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: number[]]
|
||||
}>()
|
||||
|
||||
function toggle(id: number) {
|
||||
if (props.modelValue.includes(id)) {
|
||||
emit('update:modelValue', props.modelValue.filter(v => v !== id))
|
||||
} else {
|
||||
emit('update:modelValue', [...props.modelValue, id])
|
||||
}
|
||||
}
|
||||
</script>
|
||||
224
components/EuropeMap.vue
Normal file
224
components/EuropeMap.vue
Normal file
@@ -0,0 +1,224 @@
|
||||
<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 Pratique {
|
||||
id: number
|
||||
nom: string
|
||||
lat?: number | null
|
||||
lng?: number | null
|
||||
pays?: string
|
||||
ville?: string
|
||||
type?: string
|
||||
score?: number
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
orgs: Pratique[]
|
||||
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
|
||||
|
||||
function createPinIcon(score: number, isSelected = false): DivIcon {
|
||||
const L = (window as any).L
|
||||
// Couleur selon score (1-5) : du pale au vif
|
||||
const bg = score >= 4 ? '#f5b342' : score >= 3 ? 'rgba(26,34,56,0.75)' : 'rgba(26,34,56,0.5)'
|
||||
const border = isSelected ? '#f5b342' : '#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')
|
||||
|
||||
;(window as any).L = L
|
||||
// @ts-ignore
|
||||
await import('leaflet.markercluster')
|
||||
const MarkerClusterGroup = L.MarkerClusterGroup
|
||||
|
||||
mapInstance = L.map(mapContainer.value, {
|
||||
center: [50.0, 10.0],
|
||||
zoom: 4,
|
||||
zoomControl: true,
|
||||
attributionControl: true,
|
||||
maxBounds: [[30.0, -15.0], [72.0, 40.0]],
|
||||
maxBoundsViscosity: 0.8,
|
||||
minZoom: 3,
|
||||
maxZoom: 18,
|
||||
})
|
||||
|
||||
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!)
|
||||
|
||||
clusterGroup = new MarkerClusterGroup({
|
||||
disableClusteringAtZoom: 12,
|
||||
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
|
||||
|
||||
clusterGroup.clearLayers()
|
||||
markers.clear()
|
||||
|
||||
const orgsWithCoords = props.orgs.filter(
|
||||
(o) => o.lat != null && o.lng != null
|
||||
)
|
||||
|
||||
orgsWithCoords.forEach((org) => {
|
||||
const isSelected = org.id === props.selectedId
|
||||
const icon = createPinIcon(org.score ?? 1, isSelected)
|
||||
|
||||
const marker = leaflet.marker([org.lat!, org.lng!], { icon })
|
||||
|
||||
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.pays ? `<div style="font-size: 11px; color: var(--nav-text-muted);">${org.pays}${org.ville ? ' · ' + org.ville : ''}</div>` : ''}
|
||||
${org.type ? `<div style="font-size: 11px; color: var(--nav-text-muted); margin-top: 2px;">${org.type}</div>` : ''}
|
||||
<a href="/pratique/${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)
|
||||
})
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.orgs,
|
||||
() => updateMarkers(),
|
||||
{ deep: false }
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.selectedId,
|
||||
(newId, oldId) => {
|
||||
if (!mapInstance) return
|
||||
const leaflet = (window as any).L
|
||||
if (!leaflet) return
|
||||
|
||||
if (oldId != null) {
|
||||
const oldMarker = markers.get(oldId)
|
||||
const oldOrg = props.orgs.find(o => o.id === oldId)
|
||||
if (oldMarker && oldOrg) {
|
||||
oldMarker.setIcon(createPinIcon(oldOrg.score ?? 1, false))
|
||||
}
|
||||
}
|
||||
if (newId != null) {
|
||||
const newMarker = markers.get(newId)
|
||||
const newOrg = props.orgs.find(o => o.id === newId)
|
||||
if (newMarker && newOrg) {
|
||||
newMarker.setIcon(createPinIcon(newOrg.score ?? 1, true))
|
||||
const latLng = newMarker.getLatLng()
|
||||
mapInstance.panTo(latLng, { animate: true })
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
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()
|
||||
|
||||
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>
|
||||
276
components/OutremerMapPratiques.vue
Normal file
276
components/OutremerMapPratiques.vue
Normal file
@@ -0,0 +1,276 @@
|
||||
<template>
|
||||
<div class="outremer-accordion">
|
||||
<div
|
||||
v-for="dom in DOM_TOM_PRATIQUES"
|
||||
:key="dom.code"
|
||||
class="outremer-item"
|
||||
>
|
||||
<button
|
||||
class="outremer-header"
|
||||
@click="toggle(dom.code)"
|
||||
:aria-expanded="openDom === dom.code"
|
||||
>
|
||||
<span class="outremer-title">{{ dom.label }}</span>
|
||||
<span class="outremer-meta">
|
||||
<span class="outremer-count-badge" :style="orgCounts[dom.code] === 0 ? 'opacity:0.4' : ''">
|
||||
{{ orgCounts[dom.code] ?? 0 }} fiche{{ (orgCounts[dom.code] ?? 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.code }"
|
||||
>
|
||||
<polyline points="6 9 12 15 18 9"/>
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div
|
||||
v-show="openDom === dom.code"
|
||||
class="outremer-map-container"
|
||||
>
|
||||
<div :ref="el => { if (el) mapRefs[dom.code] = el as HTMLElement }" class="outremer-map" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Map as LeafletMap, TileLayer } from 'leaflet'
|
||||
|
||||
interface Pratique {
|
||||
id: number
|
||||
nom: string
|
||||
lat?: number | null
|
||||
lng?: number | null
|
||||
pays?: string
|
||||
ville?: string
|
||||
type?: string
|
||||
score?: number
|
||||
}
|
||||
|
||||
const DOM_TOM_PRATIQUES = [
|
||||
{ code: 'GP', label: 'Guadeloupe', center: [16.25, -61.58] as [number, number], zoom: 9 },
|
||||
{ code: 'MQ', label: 'Martinique', center: [14.65, -61.02] as [number, number], zoom: 9 },
|
||||
{ code: 'GF', label: 'Guyane', center: [4.0, -53.0] as [number, number], zoom: 6 },
|
||||
{ code: 'RE', label: 'La Réunion', center: [-21.11, 55.53] as [number, number], zoom: 9 },
|
||||
{ code: 'YT', label: 'Mayotte', center: [-12.83, 45.16] as [number, number], zoom: 10 },
|
||||
{ code: 'PF', label: 'Polynésie française', center: [-17.5, -149.5] as [number, number], zoom: 8 },
|
||||
{ code: 'NC', label: 'Nouvelle-Calédonie', center: [-20.9, 165.6] as [number, number], zoom: 7 },
|
||||
]
|
||||
|
||||
const props = defineProps<{
|
||||
orgs: Pratique[]
|
||||
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_PRATIQUES.forEach(d => { counts[d.code] = 0 })
|
||||
props.orgs.forEach(o => {
|
||||
if (o.pays && counts[o.pays] !== undefined) {
|
||||
counts[o.pays]++
|
||||
}
|
||||
})
|
||||
return counts
|
||||
})
|
||||
|
||||
function toggle(code: string) {
|
||||
openDom.value = openDom.value === code ? null : code
|
||||
nextTick(() => {
|
||||
if (openDom.value === code && !mapInstances[code]) {
|
||||
initSingleMap(code)
|
||||
} else if (openDom.value === code) {
|
||||
mapInstances[code]?.invalidateSize()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function createPinIcon(L: any, score: number, isSelected = false) {
|
||||
const bg = score >= 4 ? '#f5b342' : 'rgba(26, 34, 56, 0.6)'
|
||||
const border = isSelected ? '#f5b342' : '#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(code: string) {
|
||||
const dom = DOM_TOM_PRATIQUES.find(d => d.code === code)
|
||||
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[code]
|
||||
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[code] = tileLayer as unknown as TileLayer
|
||||
mapInstances[code] = map as unknown as LeafletMap
|
||||
renderPins(L, code)
|
||||
}
|
||||
|
||||
function updateTheme(dark: boolean) {
|
||||
const url = getTileUrl(dark)
|
||||
Object.values(tileLayers).forEach(tl => {
|
||||
(tl as any).setUrl(url)
|
||||
})
|
||||
}
|
||||
|
||||
function renderPins(L: any, code: string) {
|
||||
const map = mapInstances[code] as any
|
||||
if (!map) return
|
||||
|
||||
if (map._navMarkers) {
|
||||
map._navMarkers.forEach((m: any) => m.remove())
|
||||
}
|
||||
map._navMarkers = []
|
||||
|
||||
const domOrgs = props.orgs.filter(o => o.pays === code && o.lat != null && o.lng != null)
|
||||
domOrgs.forEach(org => {
|
||||
const icon = createPinIcon(L, org.score ?? 1, org.id === props.selectedId)
|
||||
const marker = L.marker([org.lat!, org.lng!], { icon })
|
||||
|
||||
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.ville ? `<div style="font-size:11px;color:rgba(26,34,56,0.55);">${org.ville}</div>` : ''}
|
||||
${org.type ? `<div style="font-size:11px;color:rgba(26,34,56,0.55);margin-top:2px;">${org.type}</div>` : ''}
|
||||
<a href="/pratique/${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_PRATIQUES.forEach(dom => {
|
||||
if (mapInstances[dom.code]) {
|
||||
import('leaflet').then(L => renderPins(L, dom.code))
|
||||
}
|
||||
})
|
||||
}, { deep: false })
|
||||
|
||||
watch(() => props.selectedId, () => {
|
||||
DOM_TOM_PRATIQUES.forEach(dom => {
|
||||
if (mapInstances[dom.code]) {
|
||||
import('leaflet').then(L => renderPins(L, dom.code))
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
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>
|
||||
60
components/PaysFilter.vue
Normal file
60
components/PaysFilter.vue
Normal file
@@ -0,0 +1,60 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- Groupe Europe -->
|
||||
<span class="text-xs font-bold uppercase tracking-wide block mb-1.5" style="color: var(--nav-text-muted);">PAYS — EUROPE</span>
|
||||
<div class="flex flex-wrap gap-1 mb-2">
|
||||
<button
|
||||
v-for="code in EUROPE_CODES"
|
||||
:key="code"
|
||||
type="button"
|
||||
class="px-2 py-0.5 rounded-full text-xs transition-all"
|
||||
:style="modelValue.includes(code)
|
||||
? 'background: var(--nav-primary); color: var(--nav-text-on-primary); font-weight: 600;'
|
||||
: 'background: var(--nav-bg-alt); color: var(--nav-text-muted);'"
|
||||
@click="toggle(code)"
|
||||
>
|
||||
{{ PAYS_LABELS[code] ?? code }}
|
||||
<span v-if="counts && counts[code] !== undefined" class="ml-1 opacity-60 text-xs">{{ counts[code] }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Groupe Outre-mer -->
|
||||
<span class="text-xs font-bold uppercase tracking-wide block mb-1.5" style="color: var(--nav-text-muted);">OUTRE-MER</span>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<button
|
||||
v-for="code in OUTREMER_CODES"
|
||||
:key="code"
|
||||
type="button"
|
||||
class="px-2 py-0.5 rounded-full text-xs transition-all"
|
||||
:style="modelValue.includes(code)
|
||||
? 'background: var(--nav-primary); color: var(--nav-text-on-primary); font-weight: 600;'
|
||||
: 'background: var(--nav-bg-alt); color: var(--nav-text-muted);'"
|
||||
@click="toggle(code)"
|
||||
>
|
||||
{{ PAYS_LABELS[code] ?? code }}
|
||||
<span v-if="counts && counts[code] !== undefined" class="ml-1 opacity-60 text-xs">{{ counts[code] }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { EUROPE_CODES, OUTREMER_CODES, PAYS_LABELS } from '~/types/pratique'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: string[]
|
||||
counts?: Record<string, number>
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string[]]
|
||||
}>()
|
||||
|
||||
function toggle(code: string) {
|
||||
if (props.modelValue.includes(code)) {
|
||||
emit('update:modelValue', props.modelValue.filter(v => v !== code))
|
||||
} else {
|
||||
emit('update:modelValue', [...props.modelValue, code])
|
||||
}
|
||||
}
|
||||
</script>
|
||||
241
components/PratiqueSidebar.vue
Normal file
241
components/PratiqueSidebar.vue
Normal file
@@ -0,0 +1,241 @@
|
||||
<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 -->
|
||||
<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 pratique">
|
||||
<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 pratique…"
|
||||
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 -->
|
||||
<div
|
||||
class="shrink-0 px-4 pt-3 pb-3 space-y-4 border-b overflow-y-auto"
|
||||
style="border-color: var(--nav-bg-alt); max-height: 280px;"
|
||||
>
|
||||
<!-- Critères régé -->
|
||||
<CritereFilter
|
||||
:modelValue="criteres"
|
||||
:counts="critereCount"
|
||||
@update:modelValue="emit('update:criteres', $event)"
|
||||
/>
|
||||
|
||||
<!-- Type entité -->
|
||||
<TypeEntiteFilter
|
||||
:modelValue="typesEntite"
|
||||
:counts="typeCount"
|
||||
@update:modelValue="emit('update:typesEntite', $event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════════════════════════════ LISTE FICHES -->
|
||||
<div class="flex-1 flex flex-col min-h-0">
|
||||
<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>
|
||||
|
||||
<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="pratiques.length === 0" class="text-center py-8">
|
||||
<p class="text-xs" style="color: var(--nav-text-muted);">Aucun résultat</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="pratique in pratiques"
|
||||
:key="pratique.id"
|
||||
class="rounded-lg px-3 py-2 cursor-pointer transition-all"
|
||||
:style="selectedId === pratique.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-pratique', pratique.id)"
|
||||
@mouseenter="emit('hover-pratique', pratique.id)"
|
||||
@mouseleave="emit('hover-pratique', null)"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-1.5">
|
||||
<span class="font-semibold text-sm leading-snug" style="color: var(--nav-text);">{{ pratique.nom }}</span>
|
||||
<span
|
||||
v-if="pratique.pays"
|
||||
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;"
|
||||
>{{ pratique.pays }}</span>
|
||||
</div>
|
||||
<div v-if="pratique.criteres?.length" class="mt-1 flex flex-wrap gap-1">
|
||||
<span
|
||||
v-for="cId in pratique.criteres.slice(0, 3)"
|
||||
:key="cId"
|
||||
class="px-1.5 py-0.5 rounded text-xs"
|
||||
style="background: var(--nav-bg-alt); color: var(--nav-text-muted);"
|
||||
>{{ CRITERES.find(c => c.id === cId)?.label }}</span>
|
||||
</div>
|
||||
<div v-if="pratique.ville" class="mt-0.5 text-xs" style="color: var(--nav-text-muted);">
|
||||
{{ pratique.ville }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { CRITERES } from '~/types/pratique'
|
||||
|
||||
interface Pratique {
|
||||
id: number
|
||||
nom: string
|
||||
pays?: string
|
||||
ville?: string
|
||||
type?: string
|
||||
criteres?: number[]
|
||||
score?: number
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
search: string
|
||||
criteres: number[]
|
||||
typesEntite: string[]
|
||||
critereCount: Record<number, number>
|
||||
typeCount: Record<string, number>
|
||||
resultCount: number
|
||||
pratiques: Pratique[]
|
||||
selectedId: number | null
|
||||
hasActiveFilters: boolean
|
||||
pending?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:search': [value: string]
|
||||
'update:criteres': [value: number[]]
|
||||
'update:typesEntite': [value: string[]]
|
||||
'select-pratique': [id: number]
|
||||
'hover-pratique': [id: number | null]
|
||||
'reset-filters': []
|
||||
}>()
|
||||
|
||||
const searchInputEl = ref<HTMLInputElement | null>(null)
|
||||
</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>
|
||||
41
components/TypeEntiteFilter.vue
Normal file
41
components/TypeEntiteFilter.vue
Normal file
@@ -0,0 +1,41 @@
|
||||
<template>
|
||||
<div>
|
||||
<span class="text-xs font-bold uppercase tracking-wide block mb-1.5" style="color: var(--nav-text-muted);">TYPE D'ENTITÉ</span>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<button
|
||||
v-for="type in TYPES_ENTITE"
|
||||
:key="type"
|
||||
type="button"
|
||||
class="px-2 py-0.5 rounded-full text-xs transition-all"
|
||||
:style="modelValue.includes(type)
|
||||
? 'background: var(--nav-primary); color: var(--nav-text-on-primary); font-weight: 600;'
|
||||
: 'background: var(--nav-bg-alt); color: var(--nav-text-muted);'"
|
||||
@click="toggle(type)"
|
||||
>
|
||||
{{ TYPES_ENTITE_LABELS[type] ?? type }}
|
||||
<span v-if="counts && counts[type] !== undefined" class="ml-1 opacity-60 text-xs">{{ counts[type] }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { TYPES_ENTITE, TYPES_ENTITE_LABELS } from '~/types/pratique'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: string[]
|
||||
counts?: Record<string, number>
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string[]]
|
||||
}>()
|
||||
|
||||
function toggle(type: string) {
|
||||
if (props.modelValue.includes(type)) {
|
||||
emit('update:modelValue', props.modelValue.filter(v => v !== type))
|
||||
} else {
|
||||
emit('update:modelValue', [...props.modelValue, type])
|
||||
}
|
||||
}
|
||||
</script>
|
||||
Reference in New Issue
Block a user