Compare commits
14
Commits
5ce431d055
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1054172ec9 | ||
|
|
a9befb25ab | ||
|
|
a6dcf48c71 | ||
|
|
5e0027b861 | ||
|
|
1bb55538a4 | ||
|
|
fe1043e321 | ||
|
|
55027ba482 | ||
|
|
7c968c2054 | ||
|
|
0166d66fc8 | ||
|
|
d4e72942fc | ||
|
|
a98342c5f1 | ||
|
|
2e82d412ed | ||
|
|
5a4c7af79c | ||
|
|
38df1d5eb5 |
+41
-8
@@ -56,23 +56,56 @@ Pour une chip non cliquable (affichage seul, ex. liste de résultats) : `as="spa
|
||||
3. Choisis l'ordre de repli du résumé : `buildResume([champA, champB, ...])` prend le premier champ non vide, en garde la première phrase, tronque à 110 caractères sur un mot. Vérifie quels champs de ton type contiennent du texte descriptif avant de les ordonner.
|
||||
4. `chips` = 3 étiquettes maximum (fonctions, hashtags, ou l'équivalent pour ton type). `geoloc` = `latitude != null && longitude != null` si ton type a des coordonnées, sinon `true` (pas de fiche à masquer côté carte).
|
||||
|
||||
`components/FicheMiniCard.vue` consomme uniquement `MiniFiche` — il n'a jamais besoin de connaître `Org` ou `StructureV2`. C'est ce découplage qui permet d'ajouter une source sans toucher au composant d'affichage.
|
||||
`components/FicheMiniCard.vue` consomme uniquement `MiniFiche` (depuis B11, `MiniFiche` porte aussi `etiquettes`, `chipsDimension`, `coords`, `date`, `ville`, `texte` : voir §Vue fiches) — il n'a jamais besoin de connaître `Org` ou `StructureV2`. C'est ce découplage qui permet d'ajouter une source sans toucher au composant d'affichage.
|
||||
|
||||
## FichesPanel — le panneau « toutes les fiches »
|
||||
## Vue fiches (B11 02/10/2026, détail B12 03/10/2026) — `VueFiches` + `useVueFiches`
|
||||
|
||||
`components/FichesPanel.vue` : props `fiches: MiniFiche[]` (déjà filtrée par la page appelante — le panneau ne refiltre pas, ne crée pas de second champ de recherche), `hashtags: string[]` (options de filtre), `selected: string[]`, `query: string` (sert seulement à nuancer le message d'état vide), `intention: string`. Émet `update:selected` et `open(fiche)`.
|
||||
Remplace `FichesPanel`, `NavSidebar`, `MobileSheet` et les onglets Métropolitain / Outre-mer / Toutes les fiches de `/` et `/agences` (tous retirés). Spécification : `1 PROJETS/DEV/AEP/Cadrage/SPEC-vue-fiches.md` (vault).
|
||||
|
||||
**Le panneau ne possède pas son propre état de filtre** : ses chips pilotent la variable réactive de la page appelante via `update:selected` (ex. `fonctions` sur `/`, `selectedHashtags` sur `/agences`). C'est ce qui garantit qu'une chip cochée dans le panneau et la même chip dans la sidebar (si elle est visible) affichent toujours le même état — une seule source de vérité, jamais deux.
|
||||
**Trois fichiers, trois rôles.**
|
||||
|
||||
**Hashtags repliés** (B3-M4) : au-delà de 14 chips (`COLLAPSE_THRESHOLD`), seules les premières s'affichent + un chip-bouton « voir les N autres » qui déplie. Les chips déjà actives restent visibles même repliées (union entre les 14 premières et les actives hors de ce lot). État déplié non persistant — `ref` locale, reset à chaque montage. `/` (10 fonctions) ne dépasse jamais le seuil ; `/agences` (~60 hashtags) se replie par défaut.
|
||||
| Fichier | Rôle | Testé par |
|
||||
|---|---|---|
|
||||
| `utils/vueFiches.ts` | logique pure : `lireQuery` / `ecrireQuery`, `filtrerFiches`, `compterEtiquettes`, `trierFiches`, `regrouper`, `ordreFiches` ; B12 : `lireFiche`, `queryAvecFiche`, `positionFiche`, `modeOuverture` / `modeFermeture`, `empreinteListe` | `node scripts/test-vue-fiches.mjs` |
|
||||
| `composables/useVueFiches.ts` | branche la logique sur la route ; rend un objet `reactive` (`vf`) | navigateur |
|
||||
| `components/VueFiches.vue` (+ `VueFichesFiltres.vue`) | barre d'outils, trois états, filtres, grille, poignée, feuille « Filtrer », panneau de détail (B12) | navigateur |
|
||||
|
||||
**Accès** : `?vue=fiches` dans l'URL, en plus des paramètres de filtre existants. Sur `/`, `desktopMapView`/`mobileMapView` gagnent une valeur `'fiches'` à côté de `'metropole'`/`'outremer'` ; sur `/agences`, à côté de `'metropole'`/`'outremer'`/`'graphe'`. Recharger l'URL avec `?vue=fiches` doit rouvrir directement le panneau, filtres compris.
|
||||
**L'URL est la source de vérité.** L'état se lit dans `route.query` (surveillée) et s'écrit par `router.replace`. Paramètres : `vue` (`carte` · `mixte` · `fiches`, absent = défaut par largeur), `q`, un paramètre par dimension (valeurs séparées par des virgules), `adresse=sans`, `tri`, `groupe`, et celui des sous-vues de carte (`mode` sur `/`, `carte` sur `/agences`). Un paramètre inconnu ou invalide est ignoré ; un paramètre que la vue ne gère pas (`territoire`, `random`) est recopié tel quel, `fiche=` aussi (un filtre changé garde la fiche ouverte). La recherche part dans l'URL 250 ms après la dernière touche.
|
||||
|
||||
**Mobile** : le panneau ne se monte JAMAIS dans un `MobileSheet` (le composant sheet demi-hauteur pensé pour flotter au-dessus d'une carte) — il n'y a pas de carte derrière la vue fiches, donc pas de sheet à demi-hauteur : le panneau occupe toute la zone sous la barre d'onglets, avec son propre scroll interne. Piège vécu en B3 : monter le panneau dans un `MobileSheet` par réflexe (comme les autres onglets) laisse ~45 % de l'écran vide au-dessus, puisque la sheet démarre repliée à mi-hauteur et qu'il n'y a rien à voir en dessous.
|
||||
**États.** Sans `vue=` : Mixte à partir de 1024 px, Fiches en dessous. Mixte n'existe pas sous 1024 px (il se lit Fiches). Avant le montage, la racine porte `vf--auto` et la CSS tranche seule, pour qu'un lien s'affiche sans saut. La poignée ‹ › bascule Mixte ↔ Carte ; le repli est retenu en `sessionStorage` (`aep_vue_fiches_liste_repliee`) et ne compte que si l'URL n'a pas de `vue=`. En état Fiches, la carte reste montée, invisible (Leaflet garde une taille) ; les cartes appellent `invalidateSize` par `ResizeObserver`.
|
||||
|
||||
**Filtres : OU dans une dimension, ET entre dimensions.** Compteur d'une chip = fiches qui portent l'étiquette parmi celles que laissent passer la recherche et les AUTRES dimensions ; 0 = chip grisée (`disabled`). Repli au-delà de 14 valeurs, dans l'ordre de la dimension (stable). Les filtres sont à UN endroit : panneau de gauche (Mixte), tête de grille (Fiches, ordinateur et tablette), feuille du bas (téléphone) ; en Carte, seules les pastilles actives, dans la barre. Une chip cliquée dans une carte-fiche ajoute le filtre (`MiniFiche.chipsDimension`).
|
||||
|
||||
**Tri** : `pertinence` (nombre d'étiquettes cochées, puis nom ; défaut dès qu'un filtre est actif), `nom` (défaut sinon), `recent` (date puis Id décroissant ; à n'offrir que si la source a une date). **Regroupement** : une fiche va sous sa PREMIÈRE étiquette de la dimension, jamais en double.
|
||||
|
||||
**Détail et fiche à fiche (B12).** Cliquer une carte-fiche (ou une épingle : la page appelle `vf.ouvrirFiche(id)`) écrit `fiche=<id>` par `router.push` ; passer d'une fiche à l'autre (Précédente / Suivante, ← →, balayage au téléphone) par `router.replace`. Fermer (✕, « ‹ N fiches », Échap) revient en arrière si l'entrée vient de la page et que la liste n'a pas changé depuis, sinon retire `fiche=` sur place (lien partagé, filtre changé fiche ouverte) : le retour du téléphone ferme la fiche, jamais la page. La fiche est cherchée dans TOUTES les fiches : hors du résultat, le panneau dit « Hors de vos filtres » et n'a pas de Précédente / Suivante. Panneau de 380 px en troisième colonne (ordinateur, dans les trois états ; la grille Fiches passe à 2 colonnes), 340 px sur tablette, plein écran par-dessus la vue au téléphone (barre, liste et carte en `inert`, fiche à fiche en pied). Le focus entre dans le panneau à l'ouverture et revient, à la fermeture, sur la carte-fiche de la dernière fiche lue ; la position de défilement de la liste est rendue. Actions du panneau : Ouvrir la page de la fiche (prop `lien-page`), Voir sur la carte (si la fiche a une épingle dans le résultat : Mixte sur ordinateur, Carte au téléphone ; la page lit `vf.centrage` et le passe à sa carte, prop `centrage` de `NavMap` / `NavMapV2`), Copier le lien (l'URL courante dit tout). Deux écritures d'URL dans le même tick s'enchaînent (la seconde part de la query en attente), elles ne s'écrasent plus.
|
||||
|
||||
**Contrat.**
|
||||
|
||||
```vue
|
||||
<VueFiches :vf="vf" intention="Une ligne." libelle-recherche="Rechercher…" :pending="pending"
|
||||
:lien-page="(f) => `/fiche/${f.id}`" @survol="…">
|
||||
<template #carte> <!-- carte(s) de la page, une par sous-vue, v-show sur vf.etat.sousVue --> </template>
|
||||
<template #detail="{ fiche, filtrer }"> <!-- contenu propre à la source ; filtrer(dimension, valeur) au clic d'une étiquette --> </template>
|
||||
<template #vide> <!-- optionnel --> </template>
|
||||
</VueFiches>
|
||||
```
|
||||
|
||||
Contenus de détail en place : `/` → `FicheDetail` (`compact`, `etiquettes-cliquables`) + commentaires, l'`Org` venant de la liste (même enregistrement NocoDB que `/api/fiche/:id`) ; `/agences` → `FicheReseauContenu` (corps de l'ancienne `FicheModalV2`, une structure liée s'ouvre dans le même panneau). `FicheModal` et `FicheModalV2` sont retirées ; la page `/fiche/:id` reste.
|
||||
|
||||
`const vf = useVueFiches(fiches, config)` : `fiches` = `MiniFiche[]` déjà adaptées, non filtrées (ref, computed ou getter) ; `config: ConfigVueFiches` = `{ dimensions, tris, groupes, etats, sousVues? }` (types dans `utils/vueFiches.ts`). Le composant ne charge rien et ne connaît pas la carte. La page lit `vf.idsResultat` pour ne montrer sur la carte que le résultat courant, `vf.survolId` pour allumer l'épingle (et l'écrit au survol d'une épingle), `vf.ficheId` / `vf.ficheOuverte` / `vf.position` pour la fiche ouverte (B12), `vf.largeur` et `vf.vueAffichee` pour placer ses boutons flottants.
|
||||
|
||||
**Brancher une troisième page** (Codev en B12, Outils, vue Œuvres) :
|
||||
1. Adaptateur dans `useFicheAdapter.ts` : remplir `etiquettes` avec une clé par dimension (= nom du paramètre d'URL), `chipsDimension`, `coords` si la source en a (sinon `geoloc: true`).
|
||||
2. Une `ConfigVueFiches` dans la page : dimensions (variante `fonction` pour la principale, `echelle` pour la secondaire), tris (pas de `recent` sans date), groupes, `etats` (`['carte', 'fiches']` si la page n'a pas la place pour Mixte), `sousVues` si la carte en a.
|
||||
3. Le slot `#carte`, et le slot `#detail` (contenu de la fiche ; une page sans contenu de détail affiche seulement les actions). Les épingles appellent `vf.ouvrirFiche(id)`.
|
||||
4. Ajouter des cas à `scripts/test-vue-fiches.mjs` si la page introduit une règle nouvelle.
|
||||
|
||||
**`FicheMiniCard`** : racine `<article>`, toute la carte ouvre la fiche par un bouton étiré (`::after`) ; avec `etiquettes-cliquables`, les chips deviennent des boutons (impossible dans l'ancienne racine `<button>`). Props `actif` (liseré safran au survol d'épingle), `etiquettes-actives`. Résumé et « Sans localisation » en `color-mix(--text 78 %)` : `--text-muted` et `--text-subtle` échouaient au contraste.
|
||||
|
||||
## Intention d'abord
|
||||
|
||||
Principe de design validé par Jules pour B3 : un panneau (ou une carte) s'ouvre sur **une ligne d'intention** avant l'outillage (filtres, compteur, grille). La ligne d'intention dit en une phrase ce que montre l'écran et le premier geste possible (« Toutes les structures d'entraide, y compris celles sans adresse sur la carte. Cochez une fonction pour filtrer. »). Dans `FichesPanel`, l'ordre de l'en-tête est fixe : intention → compteur → chips → grille. Ne pas remonter le compteur ou les chips au-dessus de l'intention, même pour gagner de la place.
|
||||
Principe de design validé par Jules pour B3 : un panneau (ou une carte) s'ouvre sur **une ligne d'intention** avant l'outillage (filtres, compteur, grille). La ligne d'intention dit en une phrase ce que montre l'écran et le premier geste possible (« Toutes les structures d'entraide, y compris celles sans adresse sur la carte. Cochez une fonction pour filtrer. »). Dans `VueFiches`, l'intention ouvre la liste, avant les filtres et la grille. Ne pas remonter le compteur ou les chips au-dessus de l'intention, même pour gagner de la place.
|
||||
|
||||
## Motif maillage (favicon)
|
||||
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
<template>
|
||||
<div class="space-y-1.5">
|
||||
<p class="text-xs font-bold uppercase tracking-widest" style="color: var(--nav-text-muted);">Échelle</p>
|
||||
<ChipGroup aria-label="Filtrer par échelle">
|
||||
<Chip
|
||||
v-for="option in ECHELLES"
|
||||
:key="option"
|
||||
:label="option"
|
||||
variant="echelle"
|
||||
:active="isSelected(option)"
|
||||
@toggle="toggle(option)"
|
||||
/>
|
||||
</ChipGroup>
|
||||
</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>
|
||||
+57
-26
@@ -6,12 +6,14 @@
|
||||
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"
|
||||
<div :class="compact ? 'px-4 pt-4 pb-3' : 'px-6 pt-6 pb-4'" style="border-bottom: 1px solid var(--nav-bg-alt);">
|
||||
<div class="flex flex-col gap-3 mb-3" :class="compact ? '' : 'sm:flex-row sm:items-start sm:justify-between'">
|
||||
<component
|
||||
:is="compact ? 'h2' : 'h1'"
|
||||
class="font-bold leading-snug"
|
||||
:class="compact ? 'text-xl' : 'text-2xl'"
|
||||
style="color: var(--nav-text);"
|
||||
>{{ org.nom }}</h1>
|
||||
>{{ org.nom }}</component>
|
||||
|
||||
<a
|
||||
v-if="org.url"
|
||||
@@ -33,18 +35,28 @@
|
||||
</div>
|
||||
|
||||
<!-- Méta : échelle + ville -->
|
||||
<div class="flex flex-wrap items-center gap-2 text-sm mb-3" style="color: var(--nav-text-muted);">
|
||||
<div class="flex flex-wrap items-center gap-2 text-sm mb-3" style="color: color-mix(in srgb, var(--nav-text) 78%, transparent);">
|
||||
<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);"
|
||||
style="background: var(--nav-primary-solid); 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>
|
||||
<span v-if="org.territoire && org.territoire !== 'Métropole'" class="text-xs" style="color: color-mix(in srgb, var(--nav-text) 78%, transparent);">{{ org.territoire }}</span>
|
||||
<span v-if="org.localisation_ville" style="color: color-mix(in srgb, var(--nav-text) 78%, transparent);">{{ org.localisation_ville }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Tags fonction -->
|
||||
<div v-if="fonctionTags.length" class="flex flex-wrap gap-1.5">
|
||||
<ChipGroup v-if="fonctionTags.length && etiquettesCliquables" :aria-label="`Fonctions de ${org.nom}`">
|
||||
<Chip
|
||||
v-for="tag in fonctionTags"
|
||||
:key="tag"
|
||||
variant="fonction"
|
||||
:label="tag"
|
||||
:aria-label="`Filtrer par ${tag}`"
|
||||
@toggle="emit('etiquette', tag)"
|
||||
/>
|
||||
</ChipGroup>
|
||||
<div v-else-if="fonctionTags.length" class="flex flex-wrap gap-1.5">
|
||||
<span
|
||||
v-for="tag in fonctionTags"
|
||||
:key="tag"
|
||||
@@ -55,13 +67,13 @@
|
||||
</div>
|
||||
|
||||
<!-- Corps : description + mini-carte -->
|
||||
<div class="p-6 flex flex-col lg:flex-row gap-6">
|
||||
<div class="flex flex-col gap-6" :class="compact ? 'p-4' : 'p-6 lg:flex-row'">
|
||||
|
||||
<!-- 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="text-sm leading-relaxed" style="color: color-mix(in srgb, var(--nav-text) 78%, transparent);">Description communauté</p>
|
||||
<p class="mt-1 leading-relaxed" style="color: var(--nav-text);">{{ descriptionUser }}</p>
|
||||
</div>
|
||||
|
||||
@@ -70,7 +82,7 @@
|
||||
|
||||
<!-- 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);">
|
||||
<p class="text-sm leading-relaxed flex items-center gap-1.5" style="color: color-mix(in srgb, var(--nav-text) 78%, transparent);">
|
||||
<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"/>
|
||||
@@ -88,7 +100,7 @@
|
||||
|
||||
<!-- 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>
|
||||
<p class="text-sm font-medium mb-2" style="color: color-mix(in srgb, var(--nav-text) 78%, transparent);">Points clés</p>
|
||||
<ul class="space-y-1">
|
||||
<li
|
||||
v-for="(point, i) in pointsCles"
|
||||
@@ -106,15 +118,16 @@
|
||||
<!-- Mini-carte Leaflet -->
|
||||
<div
|
||||
v-if="hasCoords"
|
||||
class="shrink-0 lg:w-56 xl:w-64 rounded-xl overflow-hidden"
|
||||
class="shrink-0 rounded-xl overflow-hidden"
|
||||
:class="compact ? '' : 'lg:w-56 xl:w-64'"
|
||||
style="height: 180px; border: 1px solid var(--nav-bg-alt);"
|
||||
>
|
||||
<ClientOnly>
|
||||
<div ref="mapContainer" class="w-full h-full"></div>
|
||||
<div ref="mapContainer" class="w-full h-full" role="img" :aria-label="`Carte : ${org.nom}${org.localisation_ville ? `, ${org.localisation_ville}` : ''}`"></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);"
|
||||
style="background: var(--nav-bg-alt); color: color-mix(in srgb, var(--nav-text) 78%, transparent);"
|
||||
>Carte…</div>
|
||||
</template>
|
||||
</ClientOnly>
|
||||
@@ -140,7 +153,7 @@
|
||||
|
||||
<Transition name="report-form">
|
||||
<div v-if="reportOpen" class="report-panel">
|
||||
<p class="text-xs mb-3" style="color: var(--nav-text-muted);">
|
||||
<p class="text-xs mb-3" style="color: color-mix(in srgb, var(--nav-text) 78%, transparent);">
|
||||
Tes suggestions seront transmises à l'équipe AEP par email.
|
||||
</p>
|
||||
<div class="flex flex-col gap-3">
|
||||
@@ -171,7 +184,7 @@
|
||||
</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;">
|
||||
<p class="text-xs" style="color: color-mix(in srgb, var(--nav-text) 78%, transparent); opacity: 0.6;">
|
||||
{{ reportMessage.length }}/500
|
||||
</p>
|
||||
</div>
|
||||
@@ -186,7 +199,15 @@
|
||||
const baseTiles = useBaseTiles()
|
||||
import type { Org } from '~/types/org'
|
||||
|
||||
const props = defineProps<{ org: Org }>()
|
||||
const props = defineProps<{
|
||||
org: Org
|
||||
/** Panneau de détail de la vue fiches (B12, 380 px) : tout en colonne, titre en h2. */
|
||||
compact?: boolean
|
||||
/** Fonctions en chips cliquables : la vue fiches ajoute le filtre. */
|
||||
etiquettesCliquables?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ etiquette: [fonction: string] }>()
|
||||
|
||||
// ── Champs ──────────────────────────────────────────────────────────────
|
||||
const descriptionUser = computed(() =>
|
||||
@@ -264,15 +285,24 @@ async function submitReport() {
|
||||
// ── Mini-carte Leaflet ────────────────────────────────────────────────
|
||||
const mapContainer = ref<HTMLElement | null>(null)
|
||||
|
||||
onMounted(async () => {
|
||||
if (!hasCoords.value || !mapContainer.value) return
|
||||
// Le conteneur est dans <ClientOnly>, rendu APRÈS le onMounted de ce composant
|
||||
// quand la fiche s'ouvre côté client (panneau de la vue fiches, B12) : on
|
||||
// attend qu'il existe au lieu de le lire au montage (il valait null, la
|
||||
// mini-carte restait un cadre vide).
|
||||
let mapInit = false
|
||||
watch(mapContainer, (el) => { if (el) initMap(el) }, { flush: 'post' })
|
||||
onMounted(() => { if (mapContainer.value) initMap(mapContainer.value) })
|
||||
|
||||
async function initMap(el: HTMLElement) {
|
||||
if (mapInit || !hasCoords.value) return
|
||||
mapInit = true
|
||||
|
||||
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, {
|
||||
const map = L.map(el, {
|
||||
center: [lat, lng],
|
||||
zoom: 10,
|
||||
zoomControl: false,
|
||||
@@ -301,8 +331,9 @@ onMounted(async () => {
|
||||
iconAnchor: [7, 7],
|
||||
})
|
||||
|
||||
L.marker([lat, lng], { icon }).addTo(map)
|
||||
})
|
||||
// Épingle décorative : ni clic ni clavier (la mini-carte est une image, nommée par son conteneur)
|
||||
L.marker([lat, lng], { icon, interactive: false, keyboard: false }).addTo(map)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -312,7 +343,7 @@ onMounted(async () => {
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 0.78rem;
|
||||
color: var(--nav-text-muted);
|
||||
color: color-mix(in srgb, var(--nav-text) 78%, transparent);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
|
||||
@@ -1,12 +1,21 @@
|
||||
<!--
|
||||
FicheMiniCard — B3-M2, B11-M2
|
||||
La carte entière ouvre la fiche (bouton étiré par ::after) ; avec
|
||||
`etiquettesCliquables`, les chips deviennent des boutons qui émettent
|
||||
`etiquette` (la vue fiches ajoute le filtre). Un bouton ne pouvant pas en
|
||||
contenir d'autres, la racine est un <article>, plus un <button> : apparence
|
||||
inchangée, page /dev/fiche-mini-card comprise.
|
||||
-->
|
||||
<template>
|
||||
<button
|
||||
type="button"
|
||||
class="fiche-mini-card"
|
||||
:aria-label="fiche.nom"
|
||||
@click="emit('open')"
|
||||
>
|
||||
<span class="fiche-mini-card__nom">{{ fiche.nom }}</span>
|
||||
<span v-if="fiche.resume" class="fiche-mini-card__resume">{{ fiche.resume }}</span>
|
||||
<article class="fiche-mini-card" :class="{ 'fiche-mini-card--actif': actif }">
|
||||
<button
|
||||
type="button"
|
||||
class="fiche-mini-card__ouvrir"
|
||||
@click="emit('open')"
|
||||
>
|
||||
<span class="fiche-mini-card__nom">{{ fiche.nom }}</span>
|
||||
<span v-if="fiche.resume" class="fiche-mini-card__resume">{{ fiche.resume }}</span>
|
||||
</button>
|
||||
|
||||
<ChipGroup
|
||||
v-if="fiche.chips.length || fiche.echelle"
|
||||
@@ -14,11 +23,24 @@
|
||||
class="fiche-mini-card__chips"
|
||||
>
|
||||
<Chip v-if="fiche.echelle" as="span" variant="echelle" :label="fiche.echelle" />
|
||||
<Chip v-for="chip in fiche.chips" :key="chip" as="span" variant="fonction" :label="chip" />
|
||||
<template v-if="etiquettesCliquables">
|
||||
<Chip
|
||||
v-for="chip in fiche.chips"
|
||||
:key="chip"
|
||||
variant="fonction"
|
||||
:label="chip"
|
||||
:active="etiquettesActives?.includes(chip)"
|
||||
:aria-label="`Filtrer par ${chip}`"
|
||||
@toggle="emit('etiquette', chip)"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<Chip v-for="chip in fiche.chips" :key="chip" as="span" variant="fonction" :label="chip" />
|
||||
</template>
|
||||
</ChipGroup>
|
||||
|
||||
<span v-if="!fiche.geoloc" class="fiche-mini-card__no-geo">Sans localisation</span>
|
||||
</button>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -26,30 +48,37 @@ import type { MiniFiche } from '~/composables/useFicheAdapter'
|
||||
|
||||
defineProps<{
|
||||
fiche: MiniFiche
|
||||
etiquettesCliquables?: boolean
|
||||
/** Libellés de chips déjà filtrés (affichés pressés). */
|
||||
etiquettesActives?: string[]
|
||||
/** Fiche survolée sur la carte ou sélectionnée. */
|
||||
actif?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
open: []
|
||||
etiquette: [label: string]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fiche-mini-card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: var(--space-sm);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
box-sizing: border-box;
|
||||
transition: background-color var(--dur-fast) var(--ease-out),
|
||||
box-shadow var(--dur-fast) var(--ease-out);
|
||||
box-shadow var(--dur-fast) var(--ease-out),
|
||||
border-color var(--dur-fast) var(--ease-out);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
@@ -63,11 +92,42 @@ const emit = defineEmits<{
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.fiche-mini-card:focus-visible {
|
||||
/* Survol d'une épingle ou fiche sélectionnée : liseré safran */
|
||||
.fiche-mini-card--actif {
|
||||
border-color: var(--accent);
|
||||
box-shadow: inset 3px 0 0 var(--accent);
|
||||
}
|
||||
|
||||
.fiche-mini-card:has(.fiche-mini-card__ouvrir:focus-visible) {
|
||||
outline: 2px solid var(--focus-ring);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Bouton étiré : toute la carte ouvre la fiche, les chips cliquables passent dessus */
|
||||
.fiche-mini-card__ouvrir {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
.fiche-mini-card__ouvrir::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
}
|
||||
.fiche-mini-card__ouvrir:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.fiche-mini-card__nom {
|
||||
width: 100%;
|
||||
font-size: var(--fs-sm);
|
||||
@@ -84,7 +144,8 @@ const emit = defineEmits<{
|
||||
margin-top: var(--space-2xs);
|
||||
font-size: var(--fs-xs);
|
||||
line-height: var(--lh-base);
|
||||
color: var(--text-muted);
|
||||
/* --text-muted ne donne que 3,7:1 sur --surface (DESIGN-SYSTEM §Navigation) */
|
||||
color: color-mix(in srgb, var(--text) 78%, transparent);
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
@@ -92,12 +153,18 @@ const emit = defineEmits<{
|
||||
}
|
||||
|
||||
.fiche-mini-card__chips {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
margin-top: var(--space-xs);
|
||||
pointer-events: none;
|
||||
}
|
||||
.fiche-mini-card__chips :deep(button) {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.fiche-mini-card__no-geo {
|
||||
margin-top: var(--space-2xs);
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--text-subtle);
|
||||
color: color-mix(in srgb, var(--text) 78%, transparent);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,183 +0,0 @@
|
||||
<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="fiche-modal fixed z-[1501] left-1/2 -translate-x-1/2 flex flex-col"
|
||||
style="
|
||||
width: min(768px, 92vw);
|
||||
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>
|
||||
/* Modal positionnement : centré desktop, descendu sous le header sur mobile */
|
||||
.fiche-modal {
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
max-height: 90vh;
|
||||
}
|
||||
|
||||
@media (max-width: 1023px) {
|
||||
.fiche-modal {
|
||||
top: 76px;
|
||||
transform: translateX(-50%);
|
||||
max-height: calc(100dvh - 92px);
|
||||
}
|
||||
}
|
||||
|
||||
/* 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 (max-width: 1023px) {
|
||||
.modal-enter-from, .modal-leave-to {
|
||||
transform: translate(-50%, calc(-2% + 76px));
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.backdrop-enter-active, .backdrop-leave-active { transition: none; }
|
||||
.modal-enter-active, .modal-leave-active { transition: none; }
|
||||
}
|
||||
</style>
|
||||
@@ -1,360 +0,0 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<!-- Backdrop -->
|
||||
<Transition name="backdrop">
|
||||
<div
|
||||
v-if="modelValue && structureId != null"
|
||||
class="fixed inset-0 z-[1500]"
|
||||
style="background: rgba(26,34,56,0.55);"
|
||||
@click="close"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Transition>
|
||||
|
||||
<!-- Modal -->
|
||||
<Transition name="modal">
|
||||
<div
|
||||
v-if="modelValue && structureId != null && structure"
|
||||
class="fiche-modal-v2 fixed z-[1501] left-1/2 -translate-x-1/2 flex flex-col"
|
||||
style="
|
||||
width: min(780px, 94vw);
|
||||
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="structure?.nom ?? 'Fiche structure'"
|
||||
@keydown.esc="close"
|
||||
>
|
||||
<!-- Header modal -->
|
||||
<div
|
||||
class="flex items-center justify-between px-5 py-3 shrink-0"
|
||||
:style="`border-bottom: 3px solid ${familleColor}; background: var(--nav-surface);`"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<!-- Pastille famille -->
|
||||
<div
|
||||
class="w-3 h-3 rounded-full shrink-0"
|
||||
:style="`background: ${familleColor};`"
|
||||
/>
|
||||
<span class="text-sm font-semibold" style="color: var(--nav-text-muted);">
|
||||
{{ familleLabel }}
|
||||
</span>
|
||||
<!-- Badges -->
|
||||
<div class="flex gap-1.5">
|
||||
<span
|
||||
v-if="structure.badges.centre_ressources"
|
||||
class="px-2 py-0.5 rounded-full text-xs font-medium"
|
||||
style="background: #2d8a6b22; color: #2d8a6b;"
|
||||
>Centre ressources</span>
|
||||
<span
|
||||
v-if="structure.badges.mouvement_manifeste"
|
||||
class="px-2 py-0.5 rounded-full text-xs font-medium"
|
||||
style="background: #c44a2f22; color: #c44a2f;"
|
||||
>Manifeste</span>
|
||||
<span
|
||||
v-if="structure.badges.contre_pouvoir_spatial"
|
||||
class="px-2 py-0.5 rounded-full text-xs font-medium"
|
||||
style="background: #1a3a6b22; color: #1a3a6b;"
|
||||
>Contre-pouvoir</span>
|
||||
<span
|
||||
v-if="structure.badges.f6_recherche_politique"
|
||||
class="px-2 py-0.5 rounded-full text-xs font-medium"
|
||||
style="background: #6b3fa022; color: #6b3fa0;"
|
||||
>Recherche pol.</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<a
|
||||
v-if="structure.url"
|
||||
:href="structure.url"
|
||||
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>
|
||||
Site web
|
||||
</a>
|
||||
<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">
|
||||
<!-- Nom + meta -->
|
||||
<div class="mb-4">
|
||||
<h2 class="text-xl font-bold mb-1" style="color: var(--nav-text);">{{ structure.nom }}</h2>
|
||||
<div class="flex flex-wrap gap-2 text-sm" style="color: var(--nav-text-muted);">
|
||||
<span>{{ structure.type_principal }}</span>
|
||||
<span>·</span>
|
||||
<span>{{ structure.ville }}, {{ structure.pays }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hashtags -->
|
||||
<div v-if="structure.hashtags.length" class="flex flex-wrap gap-1.5 mb-4">
|
||||
<span
|
||||
v-for="tag in structure.hashtags"
|
||||
:key="tag"
|
||||
class="px-2 py-0.5 rounded-full text-xs"
|
||||
style="background: var(--nav-bg-alt); color: var(--nav-text-muted);"
|
||||
>{{ tag }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Description courte -->
|
||||
<p class="text-sm leading-relaxed mb-4" style="color: var(--nav-text);">{{ structure.description_courte }}</p>
|
||||
|
||||
<!-- Description longue (expandable) -->
|
||||
<div v-if="structure.description_longue" class="mb-4">
|
||||
<div
|
||||
class="text-sm leading-relaxed"
|
||||
style="color: var(--nav-text); white-space: pre-wrap;"
|
||||
:style="showFullDesc ? '' : 'max-height: 120px; overflow: hidden;'"
|
||||
>{{ structure.description_longue }}</div>
|
||||
<button
|
||||
@click="showFullDesc = !showFullDesc"
|
||||
class="mt-2 text-xs underline"
|
||||
style="color: var(--nav-text-muted);"
|
||||
>{{ showFullDesc ? 'Réduire' : 'Lire la suite…' }}</button>
|
||||
</div>
|
||||
|
||||
<!-- Pensées rattachées -->
|
||||
<div v-if="structure.pensees && structure.pensees.length" class="mb-4">
|
||||
<h3 class="text-xs font-bold uppercase tracking-wide mb-2" style="color: var(--nav-text-muted);">Pensées rattachées</h3>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<span
|
||||
v-for="pensee in structure.pensees"
|
||||
:key="pensee.id"
|
||||
class="px-2 py-0.5 rounded text-xs"
|
||||
:style="pensee.confiance === 'ia_suggested'
|
||||
? 'background: var(--nav-bg-alt); color: var(--nav-text-muted); border: 1px dashed var(--nav-bg-alt);'
|
||||
: 'background: var(--nav-bg-alt); color: var(--nav-text);'"
|
||||
:title="pensee.confiance === 'ia_suggested' ? 'IA suggéré' : ''"
|
||||
>
|
||||
{{ pensee.label }}<span v-if="pensee.confiance === 'ia_suggested'" class="ml-1 opacity-60">~</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Projets emblématiques -->
|
||||
<div v-if="projetsStructure.length" class="mb-4">
|
||||
<h3 class="text-xs font-bold uppercase tracking-wide mb-2" style="color: var(--nav-text-muted);">Projets emblématiques</h3>
|
||||
<div class="space-y-2">
|
||||
<div
|
||||
v-for="projet in projetsStructure.slice(0, 5)"
|
||||
:key="projet.id"
|
||||
class="rounded-lg p-3"
|
||||
style="background: var(--nav-surface); border: 1px solid var(--nav-bg-alt);"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<span class="font-medium text-sm" style="color: var(--nav-text);">{{ projet.nom }}</span>
|
||||
<span v-if="projet.annee" class="text-xs shrink-0" style="color: var(--nav-text-muted);">{{ projet.annee }}</span>
|
||||
</div>
|
||||
<div v-if="projet.lieu" class="text-xs mt-0.5" style="color: var(--nav-text-muted);">{{ projet.lieu }}</div>
|
||||
<p class="text-xs mt-1 leading-relaxed" style="color: var(--nav-text-muted);">{{ projet.description.slice(0, 120) }}{{ projet.description.length > 120 ? '…' : '' }}</p>
|
||||
<a
|
||||
v-if="projet.url"
|
||||
:href="projet.url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-xs mt-1 inline-block"
|
||||
style="color: var(--nav-text-muted); text-decoration: underline;"
|
||||
>En savoir plus →</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Structures voisines (graphe) -->
|
||||
<div v-if="structuresVoisines.length" class="mb-4">
|
||||
<h3 class="text-xs font-bold uppercase tracking-wide mb-2" style="color: var(--nav-text-muted);">Structures liées</h3>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="voisine in structuresVoisines.slice(0, 6)"
|
||||
:key="voisine.id"
|
||||
class="px-2 py-1 rounded text-xs transition-colors hover:opacity-70"
|
||||
style="background: var(--nav-bg-alt); color: var(--nav-text); border: 1px solid transparent;"
|
||||
@click="emit('update:structureId', voisine.id)"
|
||||
>{{ voisine.nom }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sources -->
|
||||
<div v-if="structure.sources && structure.sources.length" class="mb-4">
|
||||
<h3 class="text-xs font-bold uppercase tracking-wide mb-2" style="color: var(--nav-text-muted);">Sources</h3>
|
||||
<div class="space-y-1">
|
||||
<div v-for="(source, i) in structure.sources" :key="i" class="flex items-center gap-2">
|
||||
<span
|
||||
class="px-1.5 py-0.5 rounded text-xs shrink-0"
|
||||
style="background: var(--nav-bg-alt); color: var(--nav-text-muted);"
|
||||
>{{ source.type }}</span>
|
||||
<a
|
||||
v-if="source.url"
|
||||
:href="source.url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-xs underline truncate"
|
||||
style="color: var(--nav-text);"
|
||||
>{{ source.titre }}</a>
|
||||
<span v-else class="text-xs" style="color: var(--nav-text);">{{ source.titre }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CTAs -->
|
||||
<div class="flex gap-3 pt-2" style="border-top: 1px solid var(--nav-bg-alt);">
|
||||
<a
|
||||
href="/contribuer"
|
||||
class="text-xs underline"
|
||||
style="color: var(--nav-text-muted);"
|
||||
>Signaler une erreur</a>
|
||||
<a
|
||||
href="/contribuer"
|
||||
class="text-xs underline"
|
||||
style="color: var(--nav-text-muted);"
|
||||
>Réclamer cette fiche</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ReseauxBifurcationData, StructureV2, ProjetEmblematique } from '~/types/structure-v2'
|
||||
|
||||
const FAMILLE_COLORS: Record<number, string> = {
|
||||
1: '#a85d3e',
|
||||
2: '#c4a472',
|
||||
3: '#d4a017',
|
||||
4: '#5a7a4a',
|
||||
5: '#3d6a8c',
|
||||
}
|
||||
|
||||
const FAMILLE_LABELS: Record<number, string> = {
|
||||
1: 'Réemploi & filières',
|
||||
2: 'Frugalité & low-tech',
|
||||
3: 'Architecture sociale',
|
||||
4: 'Collectifs & AMO',
|
||||
5: 'Urbanisme de transition',
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
structureId: string | null
|
||||
data: ReseauxBifurcationData | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
'update:structureId': [id: string]
|
||||
}>()
|
||||
|
||||
const showFullDesc = ref(false)
|
||||
|
||||
function close() {
|
||||
emit('update:modelValue', false)
|
||||
showFullDesc.value = 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))
|
||||
})
|
||||
|
||||
// Remettre showFullDesc a false a chaque changement de fiche
|
||||
watch(() => props.structureId, () => {
|
||||
showFullDesc.value = false
|
||||
})
|
||||
|
||||
const structure = computed<StructureV2 | null>(() => {
|
||||
if (!props.data || !props.structureId) return null
|
||||
return props.data.structures.find(s => s.id === props.structureId) ?? null
|
||||
})
|
||||
|
||||
const familleColor = computed(() =>
|
||||
FAMILLE_COLORS[structure.value?.famille_principale ?? 1] ?? '#888'
|
||||
)
|
||||
|
||||
const familleLabel = computed(() =>
|
||||
FAMILLE_LABELS[structure.value?.famille_principale ?? 1] ?? ''
|
||||
)
|
||||
|
||||
const projetsStructure = computed<ProjetEmblematique[]>(() => {
|
||||
if (!props.data || !props.structureId) return []
|
||||
return props.data.projets?.filter(p => p.structure_parent === props.structureId) ?? []
|
||||
})
|
||||
|
||||
const structuresVoisines = computed<StructureV2[]>(() => {
|
||||
if (!props.data || !props.structureId) return []
|
||||
const edges = props.data.graphe?.edges ?? []
|
||||
const voisineIds = edges
|
||||
.filter(e => e.source === props.structureId || e.target === props.structureId)
|
||||
.sort((a, b) => (b.score ?? 0) - (a.score ?? 0))
|
||||
.map(e => e.source === props.structureId ? e.target : e.source)
|
||||
.slice(0, 8)
|
||||
|
||||
return voisineIds
|
||||
.map(id => props.data!.structures.find(s => s.id === id))
|
||||
.filter(Boolean) as StructureV2[]
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Backdrop */
|
||||
.backdrop-enter-active, .backdrop-leave-active { transition: opacity 0.2s ease; }
|
||||
.backdrop-enter-from, .backdrop-leave-to { opacity: 0; }
|
||||
|
||||
/* Modal positionnement : centré desktop, descendu sous le header sur mobile */
|
||||
.fiche-modal-v2 {
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
max-height: 90vh;
|
||||
}
|
||||
|
||||
@media (max-width: 1023px) {
|
||||
.fiche-modal-v2 {
|
||||
top: 76px;
|
||||
transform: translateX(-50%);
|
||||
max-height: calc(100dvh - 92px);
|
||||
}
|
||||
}
|
||||
|
||||
/* 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 (max-width: 1023px) {
|
||||
.modal-enter-from, .modal-leave-to {
|
||||
transform: translate(-50%, calc(-2% + 76px));
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.backdrop-enter-active, .backdrop-leave-active { transition: none; }
|
||||
.modal-enter-active, .modal-leave-active { transition: none; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,291 @@
|
||||
<!--
|
||||
FicheReseauContenu — B12-M4. Le corps de l'ancienne FicheModalV2 (retirée),
|
||||
devenu un contenu : la vue fiches le pose dans son panneau de détail
|
||||
(slot #detail de VueFiches). Badges, hashtags, descriptions, pensées,
|
||||
projets, structures liées, sources.
|
||||
|
||||
Hashtag cliqué → `etiquette` (la vue fiches ajoute le filtre) ; structure
|
||||
liée cliquée → `voisin` (la page l'ouvre dans le même panneau).
|
||||
-->
|
||||
<template>
|
||||
<article v-if="structure" class="frc">
|
||||
<div class="frc-famille" :style="`border-bottom-color: ${familleColor};`">
|
||||
<span class="frc-famille__pastille" :style="`background: ${familleColor};`" aria-hidden="true" />
|
||||
<span class="frc-famille__libelle">{{ familleLabel }}</span>
|
||||
<span v-if="structure.badges.centre_ressources" class="frc-badge" style="background: #2d8a6b22; color: #1f6650;">Centre ressources</span>
|
||||
<span v-if="structure.badges.mouvement_manifeste" class="frc-badge" style="background: #c44a2f22; color: #9a3520;">Manifeste</span>
|
||||
<span v-if="structure.badges.contre_pouvoir_spatial" class="frc-badge" style="background: #1a3a6b22; color: #1a3a6b;">Contre-pouvoir</span>
|
||||
<span v-if="structure.badges.f6_recherche_politique" class="frc-badge" style="background: #6b3fa022; color: #5a3388;">Recherche pol.</span>
|
||||
</div>
|
||||
|
||||
<h2 class="frc-nom">{{ structure.nom }}</h2>
|
||||
<p class="frc-meta">
|
||||
<span>{{ structure.type_principal }}</span>
|
||||
<span aria-hidden="true"> · </span>
|
||||
<span>{{ structure.ville }}, {{ structure.pays }}</span>
|
||||
</p>
|
||||
|
||||
<a
|
||||
v-if="structure.url"
|
||||
:href="structure.url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="frc-site"
|
||||
>
|
||||
<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>
|
||||
Site web<span class="frc-sr"> (nouvel onglet)</span>
|
||||
</a>
|
||||
|
||||
<ChipGroup v-if="structure.hashtags.length" :aria-label="`Hashtags de ${structure.nom}`" class="frc-tags">
|
||||
<Chip
|
||||
v-for="tag in structure.hashtags"
|
||||
:key="tag"
|
||||
variant="fonction"
|
||||
:label="tag"
|
||||
:active="etiquettesActives?.includes(tag)"
|
||||
:aria-label="`Filtrer par ${tag}`"
|
||||
@toggle="emit('etiquette', tag)"
|
||||
/>
|
||||
</ChipGroup>
|
||||
|
||||
<p class="frc-texte">{{ structure.description_courte }}</p>
|
||||
|
||||
<div v-if="structure.description_longue" class="frc-bloc">
|
||||
<div class="frc-texte frc-long" :class="{ 'frc-long--replie': !showFullDesc }">{{ structure.description_longue }}</div>
|
||||
<button type="button" class="frc-lien" :aria-expanded="showFullDesc" @click="showFullDesc = !showFullDesc">
|
||||
{{ showFullDesc ? 'Réduire' : 'Lire la suite…' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<section v-if="structure.pensees && structure.pensees.length" class="frc-bloc">
|
||||
<h3 class="frc-titre">Pensées rattachées</h3>
|
||||
<div class="frc-liste-inline">
|
||||
<span
|
||||
v-for="pensee in structure.pensees"
|
||||
:key="pensee.id"
|
||||
class="frc-pensee"
|
||||
:class="{ 'frc-pensee--ia': pensee.confiance === 'ia_suggested' }"
|
||||
:title="pensee.confiance === 'ia_suggested' ? 'IA suggéré' : ''"
|
||||
>{{ pensee.label }}<span v-if="pensee.confiance === 'ia_suggested'" aria-hidden="true"> ~</span><span v-if="pensee.confiance === 'ia_suggested'" class="frc-sr"> (suggéré par l'IA)</span></span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="projetsStructure.length" class="frc-bloc">
|
||||
<h3 class="frc-titre">Projets emblématiques</h3>
|
||||
<div class="frc-projets">
|
||||
<div v-for="projet in projetsStructure.slice(0, 5)" :key="projet.id" class="frc-projet">
|
||||
<div class="frc-projet__tete">
|
||||
<span class="frc-projet__nom">{{ projet.nom }}</span>
|
||||
<span v-if="projet.annee" class="frc-discret">{{ projet.annee }}</span>
|
||||
</div>
|
||||
<div v-if="projet.lieu" class="frc-discret">{{ projet.lieu }}</div>
|
||||
<p class="frc-projet__desc">{{ projet.description.slice(0, 120) }}{{ projet.description.length > 120 ? '…' : '' }}</p>
|
||||
<a v-if="projet.url" :href="projet.url" target="_blank" rel="noopener noreferrer" class="frc-lien">En savoir plus<span class="frc-sr"> sur {{ projet.nom }} (nouvel onglet)</span> →</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="structuresVoisines.length" class="frc-bloc">
|
||||
<h3 class="frc-titre">Structures liées</h3>
|
||||
<div class="frc-liste-inline">
|
||||
<button
|
||||
v-for="voisine in structuresVoisines.slice(0, 6)"
|
||||
:key="voisine.id"
|
||||
type="button"
|
||||
class="frc-voisin"
|
||||
@click="emit('voisin', voisine.id)"
|
||||
>{{ voisine.nom }}</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="structure.sources && structure.sources.length" class="frc-bloc">
|
||||
<h3 class="frc-titre">Sources</h3>
|
||||
<ul class="frc-sources">
|
||||
<li v-for="(source, i) in structure.sources" :key="i">
|
||||
<span class="frc-source__type">{{ source.type }}</span>
|
||||
<a v-if="source.url" :href="source.url" target="_blank" rel="noopener noreferrer" class="frc-lien">{{ source.titre }}</a>
|
||||
<span v-else>{{ source.titre }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<p class="frc-cta">
|
||||
<NuxtLink to="/contribuer" class="frc-lien">Signaler une erreur</NuxtLink>
|
||||
<NuxtLink to="/contribuer" class="frc-lien">Réclamer cette fiche</NuxtLink>
|
||||
</p>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ReseauxBifurcationData, StructureV2, ProjetEmblematique } from '~/types/structure-v2'
|
||||
|
||||
const FAMILLE_COLORS: Record<number, string> = {
|
||||
1: '#a85d3e',
|
||||
2: '#c4a472',
|
||||
3: '#d4a017',
|
||||
4: '#5a7a4a',
|
||||
5: '#3d6a8c',
|
||||
}
|
||||
|
||||
const FAMILLE_LABELS: Record<number, string> = {
|
||||
1: 'Réemploi & filières',
|
||||
2: 'Frugalité & low-tech',
|
||||
3: 'Architecture sociale',
|
||||
4: 'Collectifs & AMO',
|
||||
5: 'Urbanisme de transition',
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
structureId: string
|
||||
data: ReseauxBifurcationData | null
|
||||
/** Hashtags actifs dans les filtres (avec leur « # ») : la chip s'allume. */
|
||||
etiquettesActives?: string[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
etiquette: [hashtag: string]
|
||||
voisin: [id: string]
|
||||
}>()
|
||||
|
||||
const showFullDesc = ref(false)
|
||||
watch(() => props.structureId, () => { showFullDesc.value = false })
|
||||
|
||||
const structure = computed<StructureV2 | null>(() =>
|
||||
props.data?.structures.find((s) => s.id === props.structureId) ?? null,
|
||||
)
|
||||
|
||||
const familleColor = computed(() => FAMILLE_COLORS[structure.value?.famille_principale ?? 1] ?? '#888')
|
||||
const familleLabel = computed(() => FAMILLE_LABELS[structure.value?.famille_principale ?? 1] ?? '')
|
||||
|
||||
const projetsStructure = computed<ProjetEmblematique[]>(() =>
|
||||
props.data?.projets?.filter((p) => p.structure_parent === props.structureId) ?? [],
|
||||
)
|
||||
|
||||
const structuresVoisines = computed<StructureV2[]>(() => {
|
||||
if (!props.data) return []
|
||||
const edges = props.data.graphe?.edges ?? []
|
||||
return edges
|
||||
.filter((e) => e.source === props.structureId || e.target === props.structureId)
|
||||
.sort((a, b) => (b.score ?? 0) - (a.score ?? 0))
|
||||
.map((e) => (e.source === props.structureId ? e.target : e.source))
|
||||
.slice(0, 8)
|
||||
.map((id) => props.data!.structures.find((s) => s.id === id))
|
||||
.filter(Boolean) as StructureV2[]
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.frc { color: var(--text); font-size: var(--fs-sm); }
|
||||
.frc-sr {
|
||||
position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
|
||||
overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0;
|
||||
}
|
||||
|
||||
.frc-famille {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-2xs) var(--space-xs);
|
||||
padding-bottom: var(--space-xs);
|
||||
margin-bottom: var(--space-sm);
|
||||
border-bottom: 3px solid;
|
||||
}
|
||||
.frc-famille__pastille { width: 12px; height: 12px; border-radius: 50%; flex-shrink: 0; }
|
||||
.frc-famille__libelle { font-weight: var(--fw-semibold); color: color-mix(in srgb, var(--text) 78%, transparent); }
|
||||
.frc-badge {
|
||||
padding: 2px var(--space-xs);
|
||||
border-radius: var(--radius-full);
|
||||
font-size: var(--fs-xs, 0.75rem);
|
||||
font-weight: var(--fw-medium);
|
||||
}
|
||||
|
||||
.frc-nom { margin: 0 0 var(--space-2xs); font-size: 1.25rem; font-weight: var(--fw-bold, 700); line-height: 1.25; }
|
||||
.frc-meta { margin: 0 0 var(--space-sm); color: color-mix(in srgb, var(--text) 78%, transparent); }
|
||||
|
||||
.frc-site {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2xs);
|
||||
min-height: 32px;
|
||||
padding: 0 var(--space-sm);
|
||||
margin-bottom: var(--space-sm);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-alt);
|
||||
color: var(--text);
|
||||
font-weight: var(--fw-medium);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.frc-tags { margin-bottom: var(--space-sm); }
|
||||
|
||||
.frc-texte { margin: 0 0 var(--space-sm); line-height: var(--lh-base, 1.55); }
|
||||
.frc-long { white-space: pre-wrap; margin: 0; }
|
||||
.frc-long--replie { max-height: 120px; overflow: hidden; }
|
||||
|
||||
.frc-bloc { margin-bottom: var(--space-md); }
|
||||
.frc-titre {
|
||||
margin: 0 0 var(--space-xs);
|
||||
font-size: var(--fs-xs, 0.75rem);
|
||||
font-weight: var(--fw-bold, 700);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: color-mix(in srgb, var(--text) 78%, transparent);
|
||||
}
|
||||
|
||||
.frc-liste-inline { display: flex; flex-wrap: wrap; gap: var(--space-2xs); }
|
||||
.frc-pensee {
|
||||
padding: 2px var(--space-xs);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-alt);
|
||||
font-size: var(--fs-xs, 0.75rem);
|
||||
}
|
||||
.frc-pensee--ia { color: color-mix(in srgb, var(--text) 78%, transparent); border: 1px dashed var(--border); }
|
||||
|
||||
.frc-projets { display: flex; flex-direction: column; gap: var(--space-xs); }
|
||||
.frc-projet { padding: var(--space-sm); border-radius: var(--radius-md); background: var(--bg); border: 1px solid var(--border); }
|
||||
.frc-projet__tete { display: flex; justify-content: space-between; gap: var(--space-xs); }
|
||||
.frc-projet__nom { font-weight: var(--fw-medium); }
|
||||
.frc-projet__desc { margin: var(--space-2xs) 0; font-size: var(--fs-xs, 0.75rem); line-height: 1.5; color: color-mix(in srgb, var(--text) 78%, transparent); }
|
||||
.frc-discret { font-size: var(--fs-xs, 0.75rem); color: color-mix(in srgb, var(--text) 78%, transparent); }
|
||||
|
||||
.frc-voisin {
|
||||
min-height: 32px;
|
||||
padding: 0 var(--space-xs);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-alt);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-size: var(--fs-xs, 0.75rem);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.frc-sources { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: var(--space-2xs); }
|
||||
.frc-sources li { display: flex; align-items: baseline; gap: var(--space-xs); min-width: 0; font-size: var(--fs-xs, 0.75rem); overflow-wrap: anywhere; }
|
||||
.frc-source__type { flex-shrink: 0; padding: 1px var(--space-2xs); border-radius: var(--radius-sm); background: var(--bg-alt); }
|
||||
|
||||
.frc-lien {
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-size: var(--fs-xs, 0.75rem);
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.frc-cta { display: flex; gap: var(--space-sm); padding-top: var(--space-xs); margin: 0; border-top: 1px solid var(--border); }
|
||||
|
||||
.frc-site:focus-visible,
|
||||
.frc-lien:focus-visible,
|
||||
.frc-voisin:focus-visible {
|
||||
outline: 2px solid var(--focus-ring);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,168 +0,0 @@
|
||||
<!--
|
||||
FichesPanel — B3-M3
|
||||
Panneau « toutes les fiches » : reçoit une liste déjà filtrée (recherche +
|
||||
filtres en cours côté page), l'affiche en grille de FicheMiniCard, et
|
||||
expose ses propres chips de filtrage branchées sur le MÊME état que la
|
||||
page appelante (pas un second état — voir vérification du brief).
|
||||
Ne crée pas de second champ de recherche : `query` sert uniquement à
|
||||
nuancer le message d'état vide.
|
||||
-->
|
||||
<template>
|
||||
<div class="fiches-panel">
|
||||
<div class="fiches-panel__head">
|
||||
<p class="fiches-panel__intention">{{ intention }}</p>
|
||||
|
||||
<div class="fiches-panel__count">
|
||||
{{ fiches.length }} fiche{{ fiches.length > 1 ? 's' : '' }}
|
||||
</div>
|
||||
|
||||
<ChipGroup v-if="hashtags.length" aria-label="Filtrer les fiches" class="fiches-panel__chips">
|
||||
<Chip
|
||||
v-for="tag in visibleHashtags"
|
||||
:key="tag"
|
||||
:label="tag"
|
||||
variant="fonction"
|
||||
:active="selected.includes(tag)"
|
||||
@toggle="toggle(tag)"
|
||||
/>
|
||||
<Chip
|
||||
v-if="isCollapsible"
|
||||
:label="expanded ? 'Réduire' : `Voir les ${hiddenCount} autres`"
|
||||
variant="echelle"
|
||||
:active="expanded"
|
||||
@toggle="expanded = !expanded"
|
||||
/>
|
||||
</ChipGroup>
|
||||
</div>
|
||||
|
||||
<div class="fiches-panel__body">
|
||||
<p v-if="fiches.length === 0" class="fiches-panel__empty">
|
||||
Aucun résultat<template v-if="query"> pour « {{ query }} »</template>.
|
||||
</p>
|
||||
|
||||
<div v-else class="fiches-panel__grid">
|
||||
<FicheMiniCard
|
||||
v-for="fiche in fiches"
|
||||
:key="`${fiche.kind}-${fiche.id}`"
|
||||
:fiche="fiche"
|
||||
@open="emit('open', fiche)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { MiniFiche } from '~/composables/useFicheAdapter'
|
||||
|
||||
const props = defineProps<{
|
||||
fiches: MiniFiche[]
|
||||
hashtags: string[]
|
||||
selected: string[]
|
||||
query: string
|
||||
intention: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:selected': [value: string[]]
|
||||
open: [fiche: MiniFiche]
|
||||
}>()
|
||||
|
||||
// Hashtags repliés (B3-M4) — au-delà de COLLAPSE_THRESHOLD chips, n'affiche
|
||||
// que les premières + un Chip-bouton « voir les N autres ». Les chips déjà
|
||||
// actives restent toujours visibles même repliées. `/` (10 fonctions) ne
|
||||
// dépasse jamais le seuil ; `/agences` (~60 hashtags) se replie. État
|
||||
// déplié non persistant (ref locale, reset à chaque montage du composant).
|
||||
const COLLAPSE_THRESHOLD = 14
|
||||
const expanded = ref(false)
|
||||
|
||||
const isCollapsible = computed(() => props.hashtags.length > COLLAPSE_THRESHOLD)
|
||||
|
||||
const visibleHashtags = computed(() => {
|
||||
if (expanded.value || !isCollapsible.value) return props.hashtags
|
||||
const head = props.hashtags.slice(0, COLLAPSE_THRESHOLD)
|
||||
const headSet = new Set(head)
|
||||
const activeOutsideHead = props.hashtags.filter(
|
||||
(tag) => props.selected.includes(tag) && !headSet.has(tag)
|
||||
)
|
||||
return [...head, ...activeOutsideHead]
|
||||
})
|
||||
|
||||
const hiddenCount = computed(() => props.hashtags.length - visibleHashtags.value.length)
|
||||
|
||||
function toggle(tag: string) {
|
||||
if (props.selected.includes(tag)) {
|
||||
emit('update:selected', props.selected.filter((t) => t !== tag))
|
||||
} else {
|
||||
emit('update:selected', [...props.selected, tag])
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fiches-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.fiches-panel__head {
|
||||
flex-shrink: 0;
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.fiches-panel__intention {
|
||||
margin: 0 0 var(--space-xs);
|
||||
font-size: var(--fs-sm);
|
||||
line-height: var(--lh-base);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.fiches-panel__count {
|
||||
margin: 0 0 var(--space-xs);
|
||||
font-size: var(--fs-xs);
|
||||
font-weight: var(--fw-semibold);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--text-subtle);
|
||||
}
|
||||
|
||||
.fiches-panel__chips {
|
||||
margin-top: var(--space-2xs);
|
||||
}
|
||||
|
||||
.fiches-panel__body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: var(--space-md);
|
||||
}
|
||||
|
||||
.fiches-panel__empty {
|
||||
margin: var(--space-xl) 0;
|
||||
text-align: center;
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.fiches-panel__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(1, minmax(0, 1fr));
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.fiches-panel__grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1280px) {
|
||||
.fiches-panel__grid {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,52 +0,0 @@
|
||||
<template>
|
||||
<div class="space-y-1.5">
|
||||
<p class="text-xs font-bold uppercase tracking-widest" style="color: var(--nav-text-muted);">Fonction</p>
|
||||
<ChipGroup aria-label="Filtrer par fonction">
|
||||
<Chip
|
||||
v-for="fn in FONCTIONS"
|
||||
:key="fn"
|
||||
:label="fn"
|
||||
variant="fonction"
|
||||
:active="modelValue.includes(fn)"
|
||||
:count="modelValue.includes(fn) ? (modelValue.indexOf(fn) + 1) : (counts[fn] ?? 0)"
|
||||
@toggle="toggle(fn)"
|
||||
/>
|
||||
</ChipGroup>
|
||||
<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>
|
||||
@@ -1,95 +0,0 @@
|
||||
<template>
|
||||
<div class="hashtag-filter" style="padding: 8px 12px; background: var(--nav-surface);">
|
||||
<!-- Filtres famille — couleurs propres par famille (hors grammaire dimension Chip, non touché B3-M1) -->
|
||||
<div style="margin-bottom: 6px;">
|
||||
<span class="filter-label">FAMILLES</span>
|
||||
<div class="chips-row">
|
||||
<span
|
||||
v-for="fam in FAMILLES"
|
||||
:key="fam.id"
|
||||
class="chip"
|
||||
:style="selectedFamille === fam.id
|
||||
? `background: ${fam.color}; color: white; font-weight: 600; border: 2px solid ${fam.color};`
|
||||
: `background: var(--nav-bg-alt); color: ${fam.color}; border: 2px solid ${fam.color}; font-weight: 600;`"
|
||||
@click="toggleFamille(fam.id)"
|
||||
>{{ fam.shortLabel }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filtres hashtags avec toggle -->
|
||||
<div>
|
||||
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 4px;">
|
||||
<span class="filter-label">HASHTAGS</span>
|
||||
<button
|
||||
@click="hashtagsVisible = !hashtagsVisible"
|
||||
style="font-size: 0.7rem; color: var(--nav-text-muted); background: none; border: none; cursor: pointer; text-decoration: underline; padding: 0;"
|
||||
>{{ hashtagsVisible ? 'Replier' : 'Afficher (' + props.allHashtags.length + ')' }}</button>
|
||||
</div>
|
||||
<ChipGroup v-if="hashtagsVisible" aria-label="Filtrer par hashtag">
|
||||
<Chip
|
||||
v-for="tag in props.allHashtags"
|
||||
:key="tag"
|
||||
:label="tag"
|
||||
variant="fonction"
|
||||
:active="selectedHashtags.includes(tag)"
|
||||
@toggle="toggleHashtag(tag)"
|
||||
/>
|
||||
</ChipGroup>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const FAMILLES = [
|
||||
{ id: 1, shortLabel: 'Réemploi', color: '#a85d3e' },
|
||||
{ id: 2, shortLabel: 'Frugalité', color: '#c4a472' },
|
||||
{ id: 3, shortLabel: 'Social', color: '#d4a017' },
|
||||
{ id: 4, shortLabel: 'Collectifs', color: '#5a7a4a' },
|
||||
{ id: 5, shortLabel: 'Urbanisme', color: '#3d6a8c' },
|
||||
{ id: 6, shortLabel: 'Recherche', color: '#6b3fa0' },
|
||||
]
|
||||
|
||||
const props = defineProps<{
|
||||
allHashtags: string[]
|
||||
selectedHashtags: string[]
|
||||
selectedFamille: number | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:selectedHashtags': [v: string[]]
|
||||
'update:selectedFamille': [v: number | null]
|
||||
}>()
|
||||
|
||||
const hashtagsVisible = ref(false)
|
||||
|
||||
function toggleFamille(id: number) {
|
||||
emit('update:selectedFamille', props.selectedFamille === id ? null : id)
|
||||
}
|
||||
|
||||
function toggleHashtag(tag: string) {
|
||||
const current = props.selectedHashtags
|
||||
emit('update:selectedHashtags',
|
||||
current.includes(tag) ? current.filter(t => t !== tag) : [...current, tag]
|
||||
)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.filter-label {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--nav-text-muted);
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.chips-row { display: flex; flex-wrap: wrap; gap: 4px; margin-bottom: 4px; }
|
||||
.chip {
|
||||
cursor: pointer;
|
||||
padding: 3px 10px;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.75rem;
|
||||
transition: all 0.15s;
|
||||
user-select: none;
|
||||
}
|
||||
</style>
|
||||
@@ -1,218 +0,0 @@
|
||||
<!--
|
||||
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>
|
||||
+64
-11
@@ -24,10 +24,16 @@ interface Org {
|
||||
const props = defineProps<{
|
||||
orgs: Org[]
|
||||
selectedId?: number | null
|
||||
/** Fiche survolée dans la liste (B11) : épingle safran, sans recentrer. */
|
||||
survolId?: number | null
|
||||
/** « Voir sur la carte » (B12) : zoome jusqu'à l'épingle, sortie de son cluster ; `n` change à chaque demande. */
|
||||
centrage?: { id: number; n: number } | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'select-org': [id: number]
|
||||
/** Survol d'une épingle (B11) : la liste allume la carte-fiche. */
|
||||
survol: [id: number | null]
|
||||
}>()
|
||||
|
||||
const mapContainer = ref<HTMLElement | null>(null)
|
||||
@@ -35,14 +41,15 @@ let mapInstance: Map | null = null
|
||||
let clusterGroup: any = null
|
||||
const markers = new Map<number, Marker>()
|
||||
let tileLayerInstance: any = null
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
|
||||
// Créer une DivIcon pour les pins personnalisés
|
||||
function createPinIcon(isPrioritaire: boolean, isSelected = false): DivIcon {
|
||||
function createPinIcon(isPrioritaire: boolean, isSelected = false, isSurvol = 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'
|
||||
const bg = isSurvol || isPrioritaire ? '#f5b342' : 'rgba(26, 34, 56, 0.6)'
|
||||
const border = isPrioritaire || isSurvol ? '#1a2238' : '#ffffff'
|
||||
const size = isSelected || isSurvol ? 18 : 14
|
||||
const shadow = isSelected || isSurvol ? '0 0 0 4px rgba(245,179,66,0.5)' : 'none'
|
||||
|
||||
return L.divIcon({
|
||||
className: '',
|
||||
@@ -76,6 +83,7 @@ async function initMap() {
|
||||
;(window as any).L = L
|
||||
// @ts-ignore — étend L.MarkerClusterGroup en side effect
|
||||
await import('leaflet.markercluster')
|
||||
if (!mapContainer.value?.isConnected) return // page quittée pendant le chargement (ex. ?random=1)
|
||||
const MarkerClusterGroup = L.MarkerClusterGroup
|
||||
|
||||
mapInstance = L.map(mapContainer.value, {
|
||||
@@ -125,6 +133,14 @@ async function initMap() {
|
||||
|
||||
mapInstance.addLayer(clusterGroup)
|
||||
updateMarkers(L)
|
||||
|
||||
// B11 : la carte change de largeur sans que la fenêtre bouge (Mixte ↔ Carte ↔ Fiches)
|
||||
resizeObserver = new ResizeObserver(() => mapInstance?.invalidateSize({ pan: false }))
|
||||
resizeObserver.observe(mapContainer.value)
|
||||
}
|
||||
|
||||
function iconeDe(org: Org): DivIcon {
|
||||
return createPinIcon(!!org.prioritaire, org.Id === props.selectedId, org.Id === props.survolId)
|
||||
}
|
||||
|
||||
function updateMarkers(L?: any) {
|
||||
@@ -141,10 +157,7 @@ function updateMarkers(L?: any) {
|
||||
)
|
||||
|
||||
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 marker = leaflet.marker([org.latitude!, org.longitude!], { icon: iconeDe(org), title: org.nom })
|
||||
|
||||
const fonctions = org.tags_fonction
|
||||
? org.tags_fonction.split(',').map((f: string) => f.trim()).filter(Boolean).slice(0, 2).join(', ')
|
||||
@@ -163,6 +176,8 @@ function updateMarkers(L?: any) {
|
||||
`, { maxWidth: 240 })
|
||||
|
||||
marker.on('click', () => emit('select-org', org.Id))
|
||||
marker.on('mouseover', () => emit('survol', org.Id))
|
||||
marker.on('mouseout', () => emit('survol', null))
|
||||
|
||||
markers.set(org.Id, marker)
|
||||
clusterGroup.addLayer(marker)
|
||||
@@ -189,7 +204,7 @@ watch(
|
||||
const oldMarker = markers.get(oldId)
|
||||
const oldOrg = props.orgs.find(o => o.Id === oldId)
|
||||
if (oldMarker && oldOrg) {
|
||||
oldMarker.setIcon(createPinIcon(!!oldOrg.prioritaire, false))
|
||||
oldMarker.setIcon(iconeDe(oldOrg))
|
||||
}
|
||||
}
|
||||
// Mettre en avant le nouveau marker
|
||||
@@ -197,7 +212,7 @@ watch(
|
||||
const newMarker = markers.get(newId)
|
||||
const newOrg = props.orgs.find(o => o.Id === newId)
|
||||
if (newMarker && newOrg) {
|
||||
newMarker.setIcon(createPinIcon(!!newOrg.prioritaire, true))
|
||||
newMarker.setIcon(iconeDe(newOrg))
|
||||
// Centrer si visible
|
||||
const latLng = newMarker.getLatLng()
|
||||
mapInstance.panTo(latLng, { animate: true })
|
||||
@@ -206,6 +221,27 @@ watch(
|
||||
}
|
||||
)
|
||||
|
||||
// Survol depuis la liste : épingle safran, sans recentrer la carte
|
||||
watch(
|
||||
() => props.survolId,
|
||||
(newId, oldId) => {
|
||||
for (const id of [oldId, newId]) {
|
||||
if (id == null) continue
|
||||
const m = markers.get(id)
|
||||
const o = props.orgs.find(x => x.Id === id)
|
||||
if (m && o) m.setIcon(iconeDe(o))
|
||||
allumerCluster(m, id === newId)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Épingle regroupée dans un cluster : c'est le cluster visible qui s'allume
|
||||
function allumerCluster(m: Marker | undefined, on: boolean) {
|
||||
const parent = m && clusterGroup?.getVisibleParent(m)
|
||||
const el = parent && parent !== m ? (parent as any)._icon?.firstElementChild as HTMLElement | undefined : undefined
|
||||
if (el) el.style.boxShadow = on ? '0 0 0 4px #f5b342' : ''
|
||||
}
|
||||
|
||||
// Watcher dark mode — switch tuile CartoDB light_all ↔ dark_all
|
||||
function updateTileTheme(dark: boolean) {
|
||||
if (!mapInstance || !tileLayerInstance) return
|
||||
@@ -235,9 +271,26 @@ onMounted(() => {
|
||||
|
||||
onUnmounted(() => {
|
||||
themeObserver?.disconnect()
|
||||
resizeObserver?.disconnect()
|
||||
if (mapInstance) {
|
||||
mapInstance.remove()
|
||||
mapInstance = null
|
||||
}
|
||||
})
|
||||
|
||||
// « Voir sur la carte » (B12) : la carte vient peut-être de s'afficher (Fiches → Mixte) ;
|
||||
// on attend sa taille réelle, puis on sort l'épingle de son cluster.
|
||||
watch(
|
||||
() => props.centrage?.n,
|
||||
() => {
|
||||
const c = props.centrage
|
||||
if (!c) return
|
||||
setTimeout(() => {
|
||||
if (!mapInstance || !clusterGroup) return
|
||||
mapInstance.invalidateSize({ pan: false })
|
||||
const m = markers.get(c.id)
|
||||
if (m) clusterGroup.zoomToShowLayer(m, () => mapInstance?.panTo(m.getLatLng(), { animate: true }))
|
||||
}, 260)
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
+63
-10
@@ -21,10 +21,16 @@ const FAMILLE_COLORS: Record<number, string> = {
|
||||
const props = defineProps<{
|
||||
structures: StructureV2[]
|
||||
selectedId?: string | null
|
||||
/** Fiche survolée dans la liste (B11) : épingle cerclée de safran, sans recentrer. */
|
||||
survolId?: string | null
|
||||
/** « Voir sur la carte » (B12) : zoome jusqu'à l'épingle, sortie de son cluster ; `n` change à chaque demande. */
|
||||
centrage?: { id: string; n: number } | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'select-structure': [id: string]
|
||||
/** Survol d'une épingle (B11) : la liste allume la carte-fiche. */
|
||||
survol: [id: string | null]
|
||||
}>()
|
||||
|
||||
const mapContainer = ref<HTMLElement | null>(null)
|
||||
@@ -32,17 +38,18 @@ let mapInstance: Map | null = null
|
||||
let clusterGroup: any = null
|
||||
const markers = new Map<string, Marker>()
|
||||
let tileLayerInstance: any = null
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
|
||||
function getFamilleColor(famille: number): string {
|
||||
return FAMILLE_COLORS[famille] ?? '#888888'
|
||||
}
|
||||
|
||||
function createPinIcon(famille: number, isSelected = false): DivIcon {
|
||||
function createPinIcon(famille: number, isSelected = false, isSurvol = false): DivIcon {
|
||||
const L = (window as any).L
|
||||
const bg = getFamilleColor(famille)
|
||||
const size = isSelected ? 20 : 14
|
||||
const border = isSelected ? 'white' : 'rgba(255,255,255,0.7)'
|
||||
const shadow = isSelected ? `0 0 0 4px ${bg}55` : 'none'
|
||||
const size = isSelected || isSurvol ? 20 : 14
|
||||
const border = isSurvol ? '#f5b342' : isSelected ? 'white' : 'rgba(255,255,255,0.7)'
|
||||
const shadow = isSurvol ? '0 0 0 4px rgba(245,179,66,0.6)' : isSelected ? `0 0 0 4px ${bg}55` : 'none'
|
||||
|
||||
return L.divIcon({
|
||||
className: '',
|
||||
@@ -76,6 +83,7 @@ async function initMap() {
|
||||
;(window as any).L = L
|
||||
// @ts-ignore
|
||||
await import('leaflet.markercluster')
|
||||
if (!mapContainer.value?.isConnected) return // page quittée pendant le chargement (ex. ?random=1)
|
||||
const MarkerClusterGroup = L.MarkerClusterGroup
|
||||
|
||||
mapInstance = L.map(mapContainer.value, {
|
||||
@@ -121,6 +129,14 @@ async function initMap() {
|
||||
|
||||
mapInstance.addLayer(clusterGroup)
|
||||
updateMarkers(L)
|
||||
|
||||
// B11 : la carte change de largeur sans que la fenêtre bouge (Mixte ↔ Carte ↔ Fiches)
|
||||
resizeObserver = new ResizeObserver(() => mapInstance?.invalidateSize({ pan: false }))
|
||||
resizeObserver.observe(mapContainer.value)
|
||||
}
|
||||
|
||||
function iconeDe(s: StructureV2): DivIcon {
|
||||
return createPinIcon(s.famille_principale, s.id === props.selectedId, s.id === props.survolId)
|
||||
}
|
||||
|
||||
function updateMarkers(L?: any) {
|
||||
@@ -136,10 +152,7 @@ function updateMarkers(L?: any) {
|
||||
)
|
||||
|
||||
structuresWithCoords.forEach((structure) => {
|
||||
const isSelected = structure.id === props.selectedId
|
||||
const icon = createPinIcon(structure.famille_principale, isSelected)
|
||||
|
||||
const marker = leaflet.marker([structure.latitude!, structure.longitude!], { icon })
|
||||
const marker = leaflet.marker([structure.latitude!, structure.longitude!], { icon: iconeDe(structure), title: structure.nom })
|
||||
|
||||
const hashtagsHtml = structure.hashtags.slice(0, 2)
|
||||
.map(h => `<span style="font-size:10px;color:var(--nav-text-muted);">${h}</span>`)
|
||||
@@ -165,6 +178,8 @@ function updateMarkers(L?: any) {
|
||||
`, { maxWidth: 260 })
|
||||
|
||||
marker.on('click', () => emit('select-structure', structure.id))
|
||||
marker.on('mouseover', () => emit('survol', structure.id))
|
||||
marker.on('mouseout', () => emit('survol', null))
|
||||
|
||||
markers.set(structure.id, marker)
|
||||
clusterGroup.addLayer(marker)
|
||||
@@ -193,14 +208,14 @@ watch(
|
||||
const oldMarker = markers.get(oldId)
|
||||
const oldStructure = props.structures.find(s => s.id === oldId)
|
||||
if (oldMarker && oldStructure) {
|
||||
oldMarker.setIcon(createPinIcon(oldStructure.famille_principale, false))
|
||||
oldMarker.setIcon(iconeDe(oldStructure))
|
||||
}
|
||||
}
|
||||
if (newId != null) {
|
||||
const newMarker = markers.get(newId)
|
||||
const newStructure = props.structures.find(s => s.id === newId)
|
||||
if (newMarker && newStructure) {
|
||||
newMarker.setIcon(createPinIcon(newStructure.famille_principale, true))
|
||||
newMarker.setIcon(iconeDe(newStructure))
|
||||
const latLng = newMarker.getLatLng()
|
||||
mapInstance.panTo(latLng, { animate: true })
|
||||
}
|
||||
@@ -208,6 +223,27 @@ watch(
|
||||
}
|
||||
)
|
||||
|
||||
// Survol depuis la liste : épingle cerclée de safran, sans recentrer la carte
|
||||
watch(
|
||||
() => props.survolId,
|
||||
(newId, oldId) => {
|
||||
for (const id of [oldId, newId]) {
|
||||
if (id == null) continue
|
||||
const m = markers.get(id)
|
||||
const s = props.structures.find(x => x.id === id)
|
||||
if (m && s) m.setIcon(iconeDe(s))
|
||||
allumerCluster(m, id === newId)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Épingle regroupée dans un cluster : c'est le cluster visible qui s'allume
|
||||
function allumerCluster(m: Marker | undefined, on: boolean) {
|
||||
const parent = m && clusterGroup?.getVisibleParent(m)
|
||||
const el = parent && parent !== m ? (parent as any)._icon?.firstElementChild as HTMLElement | undefined : undefined
|
||||
if (el) el.style.boxShadow = on ? '0 0 0 4px #f5b342' : ''
|
||||
}
|
||||
|
||||
function updateTileTheme(dark: boolean) {
|
||||
if (!mapInstance || !tileLayerInstance) return
|
||||
const L = (window as any).L
|
||||
@@ -232,9 +268,26 @@ onMounted(() => {
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('nav-v2-select', onNavV2Select as EventListener)
|
||||
themeObserver?.disconnect()
|
||||
resizeObserver?.disconnect()
|
||||
if (mapInstance) {
|
||||
mapInstance.remove()
|
||||
mapInstance = null
|
||||
}
|
||||
})
|
||||
|
||||
// « Voir sur la carte » (B12) : la carte vient peut-être de s'afficher (Fiches → Mixte) ;
|
||||
// on attend sa taille réelle, puis on sort l'épingle de son cluster.
|
||||
watch(
|
||||
() => props.centrage?.n,
|
||||
() => {
|
||||
const c = props.centrage
|
||||
if (!c) return
|
||||
setTimeout(() => {
|
||||
if (!mapInstance || !clusterGroup) return
|
||||
mapInstance.invalidateSize({ pan: false })
|
||||
const m = markers.get(c.id)
|
||||
if (m) clusterGroup.zoomToShowLayer(m, () => mapInstance?.panTo(m.getLatLng(), { animate: true }))
|
||||
}, 260)
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -1,257 +0,0 @@
|
||||
<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-2 py-0.5 rounded-full text-xs font-medium"
|
||||
style="background: var(--nav-bg-alt); color: var(--nav-text-muted); border: 1px solid var(--nav-bg-alt); letter-spacing: 0.01em;"
|
||||
>{{ 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>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,118 @@
|
||||
<!--
|
||||
VueFichesFiltres — B11-M4. Les chips de filtre d'une vue fiches, par
|
||||
dimension (Chip.vue, variante de la dimension). Monté à UN seul endroit selon
|
||||
l'état (SPEC §3) : panneau de gauche en Mixte, tête de grille en Fiches,
|
||||
feuille du bas sur téléphone. Compteur dans le résultat courant ; chip à 0
|
||||
grisée et inactive ; au-delà de 14 valeurs, repli « + N » (actives toujours
|
||||
visibles) ; « Tout effacer » dès qu'un filtre est actif.
|
||||
-->
|
||||
<template>
|
||||
<div class="vff">
|
||||
<ChipGroup v-if="vf.etat.sansAdresse" aria-label="Filtre de localisation">
|
||||
<Chip as="span" removable active variant="echelle" label="Sans adresse" @remove="vf.setSansAdresse(false)" />
|
||||
</ChipGroup>
|
||||
<div v-for="d in vf.cfg.dimensions" :key="d.cle" class="vff__dim">
|
||||
<p :id="`${idBase}-${d.cle}`" class="vff__titre">{{ d.libelle }}</p>
|
||||
<ChipGroup :aria-label="`Filtrer par ${d.libelle.toLowerCase()}`" :aria-describedby="`${idBase}-${d.cle}`">
|
||||
<Chip
|
||||
v-for="v in visibles(d)"
|
||||
:key="v.valeur"
|
||||
:label="v.libelle"
|
||||
:variant="d.variante"
|
||||
:active="actif(d.cle, v.valeur)"
|
||||
:count="vf.compteurs[d.cle]?.[v.valeur] ?? 0"
|
||||
:disabled="!actif(d.cle, v.valeur) && !(vf.compteurs[d.cle]?.[v.valeur])"
|
||||
class="vff__chip"
|
||||
@toggle="vf.toggle(d.cle, v.valeur)"
|
||||
/>
|
||||
<button
|
||||
v-if="d.valeurs.length > SEUIL"
|
||||
type="button"
|
||||
class="vff__plus"
|
||||
:aria-expanded="!!deplies[d.cle]"
|
||||
@click="deplies[d.cle] = !deplies[d.cle]"
|
||||
>{{ deplies[d.cle] ? 'Réduire' : `+ ${d.valeurs.length - visibles(d).length}` }}</button>
|
||||
</ChipGroup>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-if="vf.nbActifs || vf.q"
|
||||
type="button"
|
||||
class="vff__effacer"
|
||||
@click="vf.effacerTout()"
|
||||
>Tout effacer</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { VueFichesEtat } from '~/composables/useVueFiches'
|
||||
import type { DimensionFiltre } from '~/utils/vueFiches'
|
||||
|
||||
const props = defineProps<{ vf: VueFichesEtat; idBase: string }>()
|
||||
|
||||
const SEUIL = 14
|
||||
const deplies = reactive<Record<string, boolean>>({})
|
||||
|
||||
function actif(dim: string, valeur: string) {
|
||||
return (props.vf.etat.filtres[dim] ?? []).includes(valeur)
|
||||
}
|
||||
|
||||
/** Les 14 premières dans l'ordre de la dimension (stable : les chips ne bougent pas au clic) + les actives hors de ce lot. */
|
||||
function visibles(d: DimensionFiltre) {
|
||||
if (d.valeurs.length <= SEUIL || deplies[d.cle]) return d.valeurs
|
||||
return d.valeurs.filter((v, i) => i < SEUIL || actif(d.cle, v.valeur))
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.vff {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.vff__titre {
|
||||
margin: 0 0 var(--space-2xs);
|
||||
font-size: var(--fs-xs);
|
||||
font-weight: var(--fw-semibold);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: color-mix(in srgb, var(--text) 78%, transparent);
|
||||
}
|
||||
|
||||
.vff__chip:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.vff__plus {
|
||||
min-height: 32px;
|
||||
padding: var(--space-2xs) var(--space-sm);
|
||||
border: 1.5px dashed color-mix(in srgb, var(--text) 40%, transparent);
|
||||
border-radius: var(--radius-full);
|
||||
background: none;
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-size: var(--fs-xs);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.vff__effacer {
|
||||
align-self: flex-start;
|
||||
padding: var(--space-2xs) 0;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-size: var(--fs-xs);
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.vff__plus:focus-visible,
|
||||
.vff__effacer:focus-visible {
|
||||
outline: 2px solid var(--focus-ring);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,12 +1,18 @@
|
||||
/**
|
||||
* useFicheAdapter — B3-M2
|
||||
* useFicheAdapter — B3-M2, étendu en B11-M2
|
||||
* Adaptateur léger Org | StructureV2 → MiniFiche, format commun consommé par
|
||||
* FicheMiniCard / FichesPanel. Aucune mutation des types sources.
|
||||
* FicheMiniCard / VueFiches. Aucune mutation des types sources.
|
||||
*
|
||||
* Repli résumé (brief B3, le champ `resume_court` du batch B2b n'existe pas
|
||||
* encore) : première phrase du premier champ non vide parmi une liste de
|
||||
* candidats ordonnée par type, tronquée proprement à ~110 caractères sur un
|
||||
* mot (jamais au milieu).
|
||||
*
|
||||
* B11 : chaque fiche porte ses `etiquettes` par dimension (clé = paramètre
|
||||
* d'URL de la dimension : `fonctions`, `echelle`, `territoire` pour l'Entraide ;
|
||||
* `tags`, `famille`, `pays` pour Réseaux AEP). C'est ce que filtre, trie et
|
||||
* regroupe `utils/vueFiches.ts` ; `chips` et `echelle` restent l'affichage
|
||||
* de la carte-fiche, inchangé.
|
||||
*/
|
||||
import type { Org } from '~/types/org'
|
||||
import type { StructureV2 } from '~/types/structure-v2'
|
||||
@@ -15,12 +21,25 @@ export interface MiniFiche {
|
||||
id: string | number
|
||||
nom: string
|
||||
resume: string
|
||||
/** Étiquettes affichées sur la carte-fiche (3 au plus). */
|
||||
chips: string[]
|
||||
/** Dimension d'où viennent `chips` : les rend cliquables dans la vue fiches. */
|
||||
chipsDimension?: string
|
||||
echelle?: string
|
||||
geoloc: boolean
|
||||
kind: 'org' | 'structure'
|
||||
/** Étiquettes complètes par dimension (valeurs telles qu'écrites dans l'URL). */
|
||||
etiquettes?: Record<string, string[]>
|
||||
coords?: [number, number]
|
||||
/** Date ISO si la source en a une (tri « récent »). */
|
||||
date?: string
|
||||
ville?: string
|
||||
/** Texte cherchable en plus du nom, de la ville et du résumé. */
|
||||
texte?: string
|
||||
}
|
||||
|
||||
export const DOM_TOM = ['Guadeloupe', 'Martinique', 'Guyane', 'La Réunion', 'Mayotte']
|
||||
|
||||
const RESUME_MAX = 110
|
||||
|
||||
/** Première phrase d'un texte (coupe sur ". "/"! "/"? " ou fin de chaîne). */
|
||||
@@ -53,32 +72,68 @@ function orgFonctions(org: Org): string[] {
|
||||
.split(',')
|
||||
.map((f) => f.trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, 3)
|
||||
}
|
||||
|
||||
export function useFicheAdapter() {
|
||||
function toMini(source: Org | StructureV2): MiniFiche {
|
||||
if (isStructure(source)) {
|
||||
return {
|
||||
id: source.id,
|
||||
nom: source.nom,
|
||||
resume: buildResume([source.description_courte, source.description_longue]),
|
||||
chips: (source.hashtags ?? []).slice(0, 3),
|
||||
geoloc: source.latitude != null && source.longitude != null,
|
||||
kind: 'structure',
|
||||
}
|
||||
}
|
||||
function coordsDe(lat?: number | null, lon?: number | null): [number, number] | undefined {
|
||||
return lat != null && lon != null ? [lat, lon] : undefined
|
||||
}
|
||||
|
||||
/** Hashtag sans son « # » : c'est la forme de l'URL (`tags=reemploi-structurel`). */
|
||||
export function sansDiese(tag: string): string {
|
||||
return tag.replace(/^#/, '')
|
||||
}
|
||||
|
||||
/** Familles d'une structure : principale, secondaires, et 6 pour le badge recherche politique. */
|
||||
function famillesDe(s: StructureV2): string[] {
|
||||
const ids = [s.famille_principale, ...(s.familles_secondaires ?? [])]
|
||||
if (s.badges?.f6_recherche_politique) ids.push(6 as any)
|
||||
return [...new Set(ids.map(String))]
|
||||
}
|
||||
|
||||
export function toMini(source: Org | StructureV2): MiniFiche {
|
||||
if (isStructure(source)) {
|
||||
const hashtags = source.hashtags ?? []
|
||||
return {
|
||||
id: source.Id,
|
||||
id: source.id,
|
||||
nom: source.nom,
|
||||
resume: buildResume([source.description_enrichie, source.description]),
|
||||
chips: orgFonctions(source),
|
||||
echelle: source.echelle,
|
||||
resume: buildResume([source.description_courte, source.description_longue]),
|
||||
chips: hashtags.slice(0, 3),
|
||||
chipsDimension: 'tags',
|
||||
geoloc: source.latitude != null && source.longitude != null,
|
||||
kind: 'org',
|
||||
kind: 'structure',
|
||||
etiquettes: {
|
||||
tags: hashtags.map(sansDiese),
|
||||
famille: famillesDe(source),
|
||||
pays: source.pays ? [source.pays] : [],
|
||||
},
|
||||
coords: coordsDe(source.latitude, source.longitude),
|
||||
ville: source.ville,
|
||||
// Portée de la recherche d'avant B11 sur /agences : description courte et hashtags.
|
||||
texte: [source.description_courte, ...hashtags].filter(Boolean).join(' '),
|
||||
}
|
||||
}
|
||||
|
||||
const fonctions = orgFonctions(source)
|
||||
return {
|
||||
id: source.Id,
|
||||
nom: source.nom,
|
||||
resume: buildResume([source.description_enrichie, source.description]),
|
||||
chips: fonctions.slice(0, 3),
|
||||
chipsDimension: 'fonctions',
|
||||
echelle: source.echelle,
|
||||
geoloc: source.latitude != null && source.longitude != null,
|
||||
kind: 'org',
|
||||
etiquettes: {
|
||||
fonctions,
|
||||
echelle: source.echelle ? [source.echelle] : [],
|
||||
territoire: [source.territoire && DOM_TOM.includes(source.territoire) ? source.territoire : 'Métropole'],
|
||||
},
|
||||
coords: coordsDe(source.latitude, source.longitude),
|
||||
date: source.submitted_at,
|
||||
ville: source.localisation_ville,
|
||||
}
|
||||
}
|
||||
|
||||
export function useFicheAdapter() {
|
||||
return { toMini }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
/**
|
||||
* useVueFiches — B11-M1. L'état de la vue fiches, branché sur l'URL.
|
||||
*
|
||||
* L'URL est la source de vérité (SPEC-vue-fiches §5) : l'état se LIT dans
|
||||
* `route.query` (surveillée : un lien interne vers `?vue=fiches` depuis la même
|
||||
* page change l'écran) et s'ÉCRIT par `router.replace` (pas d'entrée
|
||||
* d'historique par clic de filtre). Seule la recherche a un tampon local, pour
|
||||
* ne pas perdre de lettres pendant la frappe : elle part dans l'URL 250 ms
|
||||
* après la dernière touche.
|
||||
*
|
||||
* La page crée l'état (elle en a besoin pour sa carte) et le passe tel quel à
|
||||
* `<VueFiches :vf="vf">`. Logique pure et tests : `utils/vueFiches.ts`,
|
||||
* `scripts/test-vue-fiches.mjs`.
|
||||
*/
|
||||
import type { MaybeRefOrGetter } from 'vue'
|
||||
import type { MiniFiche } from '~/composables/useFicheAdapter'
|
||||
import {
|
||||
lireQuery, ecrireQuery, filtresValides, filtrerFiches, compterEtiquettes,
|
||||
triEffectif, trierFiches, regrouper, ordreFiches, nbFiltresActifs,
|
||||
lireFiche, queryAvecFiche, positionFiche, modeOuverture, modeFermeture, empreinteListe,
|
||||
type ConfigVueFiches, type EtatFiches, type EtatVue, type TriFiches,
|
||||
} from '~/utils/vueFiches'
|
||||
|
||||
/** Largeur d'écran ; null avant le montage (SSR) : la CSS tranche alors seule. */
|
||||
export type Largeur = 'telephone' | 'tablette' | 'ordinateur' | null
|
||||
|
||||
const CLE_REPLI = 'aep_vue_fiches_liste_repliee'
|
||||
|
||||
export function useVueFiches(
|
||||
fiches: MaybeRefOrGetter<MiniFiche[]>,
|
||||
config: MaybeRefOrGetter<ConfigVueFiches>,
|
||||
) {
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const cfg = computed(() => toValue(config))
|
||||
|
||||
// ── Lecture ──
|
||||
const brut = computed(() => lireQuery(route.query, cfg.value))
|
||||
|
||||
const q = ref(brut.value.q)
|
||||
let qEcrit = q.value
|
||||
let minuteurQ: ReturnType<typeof setTimeout> | null = null
|
||||
watch(() => brut.value.q, (v) => {
|
||||
if (v !== qEcrit) { q.value = v; qEcrit = v }
|
||||
})
|
||||
|
||||
const etat = computed<EtatFiches>(() => ({
|
||||
...brut.value,
|
||||
q: q.value,
|
||||
filtres: filtresValides(brut.value.filtres, cfg.value),
|
||||
}))
|
||||
|
||||
// ── Écriture ──
|
||||
// La navigation est asynchrone : une query écrite n'est dans `route.query`
|
||||
// qu'au tick suivant. Deux écritures du même tick (« Voir sur la carte » :
|
||||
// Mixte, puis la sous-vue de la page) partent donc de la dernière query
|
||||
// écrite, sinon la seconde efface la première.
|
||||
let enAttente: Record<string, string> | null = null
|
||||
watch(() => route.query, () => { enAttente = null })
|
||||
const queryBase = () => enAttente ?? route.query
|
||||
function naviguer(query: Record<string, string>, mode: 'push' | 'replace' = 'replace') {
|
||||
enAttente = query
|
||||
if (mode === 'push') router.push({ query })
|
||||
else router.replace({ query })
|
||||
}
|
||||
function etatDepuis(base: Record<string, unknown>): EtatFiches {
|
||||
const b = lireQuery(base, cfg.value)
|
||||
return { ...b, q: q.value, filtres: filtresValides(b.filtres, cfg.value) }
|
||||
}
|
||||
|
||||
function ecrire(partiel: Partial<EtatFiches>) {
|
||||
const base = queryBase()
|
||||
const nouvel = { ...etatDepuis(base), ...partiel }
|
||||
qEcrit = nouvel.q
|
||||
if (minuteurQ && partiel.q === undefined) { clearTimeout(minuteurQ); minuteurQ = null }
|
||||
naviguer(ecrireQuery(nouvel, cfg.value, base))
|
||||
}
|
||||
|
||||
function setQ(v: string) {
|
||||
q.value = v
|
||||
if (minuteurQ) clearTimeout(minuteurQ)
|
||||
minuteurQ = setTimeout(() => { minuteurQ = null; ecrire({ q: v }) }, 250)
|
||||
}
|
||||
|
||||
function avecFiltre(dim: string, valeurs: string[]) {
|
||||
return { ...etat.value.filtres, [dim]: valeurs }
|
||||
}
|
||||
function toggle(dim: string, valeur: string) {
|
||||
const vals = etat.value.filtres[dim] ?? []
|
||||
ecrire({ filtres: avecFiltre(dim, vals.includes(valeur) ? vals.filter((v) => v !== valeur) : [...vals, valeur]) })
|
||||
}
|
||||
function ajouterFiltre(dim: string, valeur: string) {
|
||||
const vals = etat.value.filtres[dim] ?? []
|
||||
if (!vals.includes(valeur)) ecrire({ filtres: avecFiltre(dim, [...vals, valeur]) })
|
||||
}
|
||||
function effacerTout() {
|
||||
q.value = ''
|
||||
const vides: Record<string, string[]> = {}
|
||||
for (const d of cfg.value.dimensions) vides[d.cle] = []
|
||||
ecrire({ q: '', filtres: vides, sansAdresse: false })
|
||||
}
|
||||
const setTri = (tri: TriFiches) => ecrire({ tri })
|
||||
const setGroupe = (groupe: string | null) => ecrire({ groupe })
|
||||
const setSousVue = (sousVue: string) => ecrire({ sousVue })
|
||||
const setSansAdresse = (sansAdresse: boolean) => ecrire({ sansAdresse })
|
||||
|
||||
// ── Largeur et état affiché ──
|
||||
const largeur = ref<Largeur>(null)
|
||||
const listeRepliee = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
const mq768 = window.matchMedia('(min-width: 768px)')
|
||||
const mq1024 = window.matchMedia('(min-width: 1024px)')
|
||||
const maj = () => {
|
||||
largeur.value = mq1024.matches ? 'ordinateur' : mq768.matches ? 'tablette' : 'telephone'
|
||||
}
|
||||
maj()
|
||||
mq768.addEventListener('change', maj)
|
||||
mq1024.addEventListener('change', maj)
|
||||
onBeforeUnmount(() => {
|
||||
mq768.removeEventListener('change', maj)
|
||||
mq1024.removeEventListener('change', maj)
|
||||
})
|
||||
try { listeRepliee.value = sessionStorage.getItem(CLE_REPLI) === '1' } catch {}
|
||||
})
|
||||
|
||||
/**
|
||||
* État rendu. `auto` avant le montage quand l'URL ne tranche pas (ou dit
|
||||
* `mixte`) : la CSS rend Mixte à partir de 1024 px et Fiches en dessous, sans
|
||||
* saut à l'hydratation. Mixte n'existe pas sous 1024 px : il se lit Fiches.
|
||||
*/
|
||||
const vueAffichee = computed<EtatVue | 'auto'>(() => {
|
||||
const etats = cfg.value.etats
|
||||
const v = etat.value.vue
|
||||
// `auto` rend Mixte à partir de 1024 px : seulement pour une page qui a cet état
|
||||
if (largeur.value === null) return v && v !== 'mixte' ? v : etats.includes('mixte') ? 'auto' : 'fiches'
|
||||
const ordinateur = largeur.value === 'ordinateur'
|
||||
if (v === 'mixte' || v === null) {
|
||||
if (!ordinateur || !etats.includes('mixte')) return 'fiches'
|
||||
if (v === null && listeRepliee.value) return 'carte'
|
||||
return 'mixte'
|
||||
}
|
||||
return v
|
||||
})
|
||||
|
||||
function setVue(v: EtatVue) {
|
||||
if (largeur.value === 'ordinateur' && (v === 'mixte' || v === 'carte')) {
|
||||
listeRepliee.value = v === 'carte'
|
||||
try { sessionStorage.setItem(CLE_REPLI, listeRepliee.value ? '1' : '0') } catch {}
|
||||
}
|
||||
ecrire({ vue: v })
|
||||
}
|
||||
|
||||
// ── Résultat ──
|
||||
const sansAdresseIgnore = computed(() => ({ ...etat.value, sansAdresse: false }))
|
||||
const filtrees = computed(() => filtrerFiches(toValue(fiches), etat.value, cfg.value))
|
||||
const tri = computed(() => triEffectif(etat.value, cfg.value))
|
||||
const resultat = computed(() => trierFiches(filtrees.value, tri.value, etat.value))
|
||||
const groupeDef = computed(() => cfg.value.groupes.find((g) => g.cle === etat.value.groupe) ?? null)
|
||||
const groupes = computed(() => regrouper(resultat.value, groupeDef.value, cfg.value))
|
||||
const ordre = computed(() => ordreFiches(groupes.value))
|
||||
const compteurs = computed(() => compterEtiquettes(toValue(fiches), etat.value, cfg.value))
|
||||
const idsResultat = computed(() => new Set(resultat.value.map((f) => String(f.id))))
|
||||
const nbSansAdresse = computed(() =>
|
||||
filtrerFiches(toValue(fiches), sansAdresseIgnore.value, cfg.value).filter((f) => !f.geoloc).length,
|
||||
)
|
||||
const total = computed(() => toValue(fiches).length)
|
||||
const nbActifs = computed(() => nbFiltresActifs(etat.value))
|
||||
|
||||
function voirSansAdresse() {
|
||||
ecrire({ sansAdresse: true, vue: 'fiches' })
|
||||
}
|
||||
|
||||
/** Fiche survolée (liste ↔ épingle) ; la carte de la page s'en sert, et peut l'écrire. */
|
||||
const survolId = ref<string | number | null>(null)
|
||||
|
||||
// ── Fiche ouverte (B12) : `fiche=` dans l'URL, lu aussi au rendu serveur ──
|
||||
const ficheId = computed(() => lireFiche(route.query))
|
||||
/** La fiche ouverte, cherchée dans TOUTES les fiches (elle peut être hors filtres). null = pas encore chargée ou inconnue. */
|
||||
const ficheOuverte = computed<MiniFiche | null>(() => {
|
||||
const id = ficheId.value
|
||||
return id == null ? null : toValue(fiches).find((f) => String(f.id) === id) ?? null
|
||||
})
|
||||
/** Rang dans l'ordre courant ; null = hors de vos filtres (ou pas de fiche). */
|
||||
const position = computed(() => positionFiche(ordre.value, ficheId.value))
|
||||
/**
|
||||
* Empreinte de la liste au moment où CETTE page a ajouté l'entrée
|
||||
* d'historique de la fiche ; null = fiche venue d'un lien partagé.
|
||||
*/
|
||||
let listeALOuverture: string | null = null
|
||||
|
||||
function ouvrirFiche(id: string | number) {
|
||||
if (String(id) === lireFiche(queryBase())) return
|
||||
const base = queryBase()
|
||||
const mode = modeOuverture(lireFiche(base))
|
||||
if (mode === 'push') listeALOuverture = empreinteListe(base)
|
||||
naviguer(queryAvecFiche(base, id), mode)
|
||||
}
|
||||
|
||||
function fermerFiche() {
|
||||
if (lireFiche(queryBase()) == null) return
|
||||
const mode = modeFermeture(listeALOuverture != null, listeALOuverture === empreinteListe(queryBase()))
|
||||
listeALOuverture = null
|
||||
if (mode === 'back') { enAttente = null; router.back() }
|
||||
else naviguer(queryAvecFiche(queryBase(), null))
|
||||
}
|
||||
|
||||
/** Étiquette cliquée dans le détail : ajoute le filtre ; `fermer` (téléphone) referme la fiche dans le même geste. */
|
||||
function filtrerDepuisFiche(dim: string, valeur: string, fermer: boolean) {
|
||||
const base = queryBase()
|
||||
const e = etatDepuis(base)
|
||||
const vals = e.filtres[dim] ?? []
|
||||
const filtres = vals.includes(valeur) ? e.filtres : { ...e.filtres, [dim]: [...vals, valeur] }
|
||||
const query = ecrireQuery({ ...e, filtres }, cfg.value, base)
|
||||
if (fermer) listeALOuverture = null
|
||||
naviguer(fermer ? queryAvecFiche(query, null) : query)
|
||||
}
|
||||
|
||||
// Retour arrière du navigateur (ou lien interne) : l'URL a perdu `fiche=`.
|
||||
watch(ficheId, (id) => { if (id == null) listeALOuverture = null })
|
||||
|
||||
const fichePrecedente = () => { if (position.value?.precedente) ouvrirFiche(position.value.precedente.id) }
|
||||
const ficheSuivante = () => { if (position.value?.suivante) ouvrirFiche(position.value.suivante.id) }
|
||||
|
||||
/**
|
||||
* « Voir sur la carte » : ordinateur → Mixte, la fiche reste ouverte ;
|
||||
* téléphone et tablette (pas de Mixte) → Carte, la fiche se ferme (elle
|
||||
* couvrirait la carte). La page lit `centrage` et centre son épingle.
|
||||
*/
|
||||
const centrage = ref<{ id: string; n: number } | null>(null)
|
||||
function voirSurCarte(id: string | number) {
|
||||
centrage.value = { id: String(id), n: (centrage.value?.n ?? 0) + 1 }
|
||||
if (largeur.value === 'ordinateur' && cfg.value.etats.includes('mixte')) {
|
||||
setVue('mixte')
|
||||
return
|
||||
}
|
||||
listeALOuverture = null
|
||||
const base = queryBase()
|
||||
naviguer(queryAvecFiche(ecrireQuery({ ...etatDepuis(base), vue: 'carte' }, cfg.value, base), null))
|
||||
}
|
||||
|
||||
return reactive({
|
||||
cfg, etat, q, largeur, vueAffichee, tri, resultat, groupes, ordre, compteurs,
|
||||
idsResultat, nbSansAdresse, total, nbActifs, survolId,
|
||||
ficheId, ficheOuverte, position, centrage,
|
||||
setVue, setQ, toggle, ajouterFiltre, effacerTout, setTri, setGroupe, setSousVue,
|
||||
setSansAdresse, voirSansAdresse,
|
||||
ouvrirFiche, fermerFiche, fichePrecedente, ficheSuivante, voirSurCarte, filtrerDepuisFiche,
|
||||
})
|
||||
}
|
||||
|
||||
export type VueFichesEtat = ReturnType<typeof useVueFiches>
|
||||
+133
-549
@@ -1,453 +1,94 @@
|
||||
<template>
|
||||
<div class="flex h-full overflow-hidden" style="background: var(--nav-bg);">
|
||||
<div class="relative h-full overflow-hidden" style="background: var(--nav-bg);">
|
||||
|
||||
<!-- ═══════════════════════════════════════ SIDEBAR DESKTOP (>= 1024px) -->
|
||||
<!-- Masquée en vue « Toutes les fiches » (B3-M3, décision Jules axe 2) :
|
||||
le panneau EST la liste (+ ses propres chips hashtags), la sidebar
|
||||
ferait doublon. IntentionBanner reste monté (Teleport, insensible au
|
||||
v-show de son conteneur). -->
|
||||
<div
|
||||
v-show="desktopMapView !== 'fiches'"
|
||||
class="hidden lg:block overflow-y-auto"
|
||||
style="width: 320px; min-width: 320px; flex-shrink: 0; border-right: 1px solid var(--nav-bg-alt); height: 100%;"
|
||||
<!-- IntentionBanner s'auto-affiche via Teleport (overlay plein écran) -->
|
||||
<IntentionBanner />
|
||||
|
||||
<!-- ═══════════════════════════════════════ VUE FICHES (B11) : Carte · Mixte · Fiches -->
|
||||
<VueFiches
|
||||
:vf="vf"
|
||||
intention="120 réseaux, collectifs et agences où des pensées écologiques deviennent des pratiques d'architecture."
|
||||
libelle-recherche="Rechercher une structure…"
|
||||
:pending="pending"
|
||||
:selected-id="selectedId"
|
||||
>
|
||||
|
||||
<!-- IntentionBanner s'auto-affiche via Teleport (overlay plein ecran) -->
|
||||
<IntentionBanner />
|
||||
|
||||
<!-- Filtres familles + hashtags -->
|
||||
<HashtagFilter
|
||||
:allHashtags="allHashtags"
|
||||
:selectedHashtags="selectedHashtags"
|
||||
:selectedFamille="selectedFamille"
|
||||
@update:selectedHashtags="selectedHashtags = $event"
|
||||
@update:selectedFamille="selectedFamille = $event"
|
||||
/>
|
||||
|
||||
<!-- Separateur -->
|
||||
<div style="height: 1px; background: var(--nav-bg-alt);"></div>
|
||||
|
||||
<!-- Barre de recherche -->
|
||||
<div class="px-3 py-2" style="border-bottom: 1px solid var(--nav-bg-alt);">
|
||||
<label class="sidebar-search-label" aria-label="Rechercher une structure">
|
||||
<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
|
||||
v-model="search"
|
||||
type="search"
|
||||
placeholder="Rechercher une structure..."
|
||||
class="sidebar-search-input"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<button
|
||||
v-if="search"
|
||||
type="button"
|
||||
class="sidebar-search-clear"
|
||||
aria-label="Effacer"
|
||||
@click.stop="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>
|
||||
|
||||
<!-- Header compteur + reset -->
|
||||
<div class="flex items-center justify-between px-4 py-2" style="border-bottom: 1px solid var(--nav-bg-alt);">
|
||||
<span class="text-xs font-bold uppercase tracking-widest" style="color: var(--nav-text-muted);">
|
||||
{{ filtered.length }} structure{{ filtered.length > 1 ? 's' : '' }}
|
||||
</span>
|
||||
<button
|
||||
v-if="hasActiveFilters"
|
||||
@click="resetFilters"
|
||||
class="text-xs underline hover:opacity-70"
|
||||
style="color: var(--nav-text-muted);"
|
||||
>Effacer les filtres</button>
|
||||
</div>
|
||||
|
||||
<!-- Liste fiches (sidebar entiere scroll - pas de scroll interne) -->
|
||||
<div class="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="filtered.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="structure in filtered"
|
||||
:key="structure.id"
|
||||
class="rounded-lg px-3 py-2 cursor-pointer transition-all"
|
||||
:style="selectedId === structure.id
|
||||
? `background: var(--nav-bg-alt); border-left: 3px solid ${familleColor(structure.famille_principale)}; padding-left: 9px;`
|
||||
: 'background: var(--nav-bg); border-left: 3px solid transparent; padding-left: 9px;'"
|
||||
@click="onSelectStructure(structure.id)"
|
||||
@mouseenter="hoveredId = structure.id"
|
||||
@mouseleave="hoveredId = null"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-1.5">
|
||||
<span class="font-semibold text-sm leading-snug" style="color: var(--nav-text);">{{ structure.nom }}</span>
|
||||
<span
|
||||
class="shrink-0 w-2.5 h-2.5 rounded-full mt-1"
|
||||
:style="`background: ${familleColor(structure.famille_principale)};`"
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-0.5 text-xs" style="color: var(--nav-text-muted);">{{ structure.type_principal }} - {{ structure.ville }}</div>
|
||||
<ChipGroup v-if="structure.hashtags.length" class="mt-1" :aria-label="`Hashtags de ${structure.nom}`">
|
||||
<Chip
|
||||
v-for="tag in structure.hashtags.slice(0, 2)"
|
||||
:key="tag"
|
||||
as="span"
|
||||
variant="fonction"
|
||||
:label="tag"
|
||||
/>
|
||||
</ChipGroup>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- ═══════════════════════════════════════ ZONE CENTRALE (carte) -->
|
||||
<main class="flex-1 flex flex-col overflow-hidden relative">
|
||||
|
||||
<!-- ── VUE DESKTOP : Onglets Métro/Outre-mer ── -->
|
||||
<div class="hidden lg:flex lg:flex-1 lg:flex-col lg:overflow-hidden">
|
||||
<!-- Onglets desktop -->
|
||||
<div class="shrink-0 flex" style="background: var(--nav-surface); border-bottom: 1px solid var(--nav-bg-alt);">
|
||||
<button
|
||||
class="px-5 py-2 text-sm font-medium transition-colors"
|
||||
:style="desktopMapView === 'metropole'
|
||||
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
|
||||
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
|
||||
@click="setDesktopView('metropole')"
|
||||
>Métropolitain</button>
|
||||
<button
|
||||
class="px-5 py-2 text-sm font-medium transition-colors"
|
||||
:style="desktopMapView === 'outremer'
|
||||
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
|
||||
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
|
||||
@click="setDesktopView('outremer')"
|
||||
>Outre-mer</button>
|
||||
<button
|
||||
class="px-5 py-2 text-sm font-medium transition-colors"
|
||||
:style="desktopMapView === 'graphe'
|
||||
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
|
||||
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
|
||||
@click="setDesktopView('graphe')"
|
||||
>Vue graphique</button>
|
||||
<button
|
||||
class="px-5 py-2 text-sm font-medium transition-colors"
|
||||
:style="desktopMapView === 'fiches'
|
||||
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
|
||||
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
|
||||
@click="setDesktopView('fiches')"
|
||||
>Toutes les fiches</button>
|
||||
</div>
|
||||
|
||||
<!-- Carte Métropole desktop -->
|
||||
<div v-show="desktopMapView === 'metropole'" class="flex-1 flex flex-col overflow-hidden">
|
||||
<div class="relative flex-1" style="min-height: 200px;">
|
||||
<ClientOnly>
|
||||
<NavMapV2
|
||||
ref="navMapRef"
|
||||
:structures="metropoleStructures"
|
||||
:selectedId="selectedId"
|
||||
@select-structure="onSelectStructure"
|
||||
/>
|
||||
<template #fallback>
|
||||
<div
|
||||
class="w-full h-full flex items-center justify-center"
|
||||
style="background: var(--nav-bg-alt); color: var(--nav-text-muted);"
|
||||
>
|
||||
Chargement de la carte…
|
||||
</div>
|
||||
</template>
|
||||
</ClientOnly>
|
||||
</div>
|
||||
<ChatbotPlaceholder
|
||||
@highlightOrgs="() => {}"
|
||||
@applyHashtag="(tag) => { if (!selectedHashtags.includes(tag)) selectedHashtags = [...selectedHashtags, tag] }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Carte Outre-mer desktop -->
|
||||
<div v-show="desktopMapView === 'outremer'" class="flex-1 overflow-y-auto" style="background: var(--nav-bg);">
|
||||
<ClientOnly>
|
||||
<OutremerMap
|
||||
:orgs="outremerOrgsLegacy"
|
||||
:selectedId="selectedIdLegacyNum"
|
||||
@select-org="() => {}"
|
||||
/>
|
||||
<template #fallback>
|
||||
<div class="flex items-center justify-center h-full text-sm" style="color: var(--nav-text-muted);">
|
||||
Chargement…
|
||||
</div>
|
||||
</template>
|
||||
</ClientOnly>
|
||||
</div>
|
||||
|
||||
<!-- Vue graphique desktop -->
|
||||
<div v-show="desktopMapView === 'graphe'" class="flex-1 overflow-hidden flex flex-col">
|
||||
<div class="flex-1 overflow-hidden relative">
|
||||
<ClientOnly>
|
||||
<GraphView
|
||||
:data="bifurcationData"
|
||||
:allHashtags="allHashtags"
|
||||
:active="desktopMapView === 'graphe'"
|
||||
@select-structure="onSelectStructure"
|
||||
/>
|
||||
<template #fallback>
|
||||
<div class="flex items-center justify-center h-full" style="color: var(--nav-text-muted);">
|
||||
Chargement du graphe...
|
||||
</div>
|
||||
</template>
|
||||
</ClientOnly>
|
||||
</div>
|
||||
<ChatbotPlaceholder
|
||||
@highlightOrgs="() => {}"
|
||||
@applyHashtag="(tag) => { if (!selectedHashtags.includes(tag)) selectedHashtags = [...selectedHashtags, tag] }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Panneau « Toutes les fiches » desktop (B3-M3) — la sidebar est
|
||||
masquée dans cette vue (décision Jules axe 2), le panneau EST
|
||||
la liste, avec ses propres chips hashtags sur le même état. -->
|
||||
<div v-show="desktopMapView === 'fiches'" class="flex-1 flex flex-col overflow-hidden">
|
||||
<FichesPanel
|
||||
:fiches="allStructuresMini"
|
||||
:hashtags="allHashtags"
|
||||
:selected="selectedHashtags"
|
||||
:query="search"
|
||||
intention="Toutes les agences et réseaux du répertoire, filtrables par hashtag."
|
||||
@update:selected="selectedHashtags = $event"
|
||||
@open="(fiche) => onSelectStructure(String(fiche.id))"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── VUE MOBILE : Onglets Métro/Outre-mer/Graphique/Fiches + sheet swipable ── -->
|
||||
<div class="lg:hidden shrink-0 flex" style="background: var(--nav-surface); border-bottom: 1px solid var(--nav-bg-alt);">
|
||||
<button
|
||||
class="flex-1 py-2 text-sm font-medium transition-colors"
|
||||
:style="mobileMapView === 'metropole'
|
||||
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
|
||||
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
|
||||
@click="setMobileView('metropole')"
|
||||
>Métropolitain</button>
|
||||
<button
|
||||
class="flex-1 py-2 text-sm font-medium transition-colors"
|
||||
:style="mobileMapView === 'outremer'
|
||||
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
|
||||
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
|
||||
@click="setMobileView('outremer')"
|
||||
>Outre-mer</button>
|
||||
<button
|
||||
class="flex-1 py-2 text-sm font-medium transition-colors"
|
||||
:style="mobileMapView === 'graphe'
|
||||
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
|
||||
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
|
||||
@click="setMobileView('graphe')"
|
||||
>Graphe</button>
|
||||
<button
|
||||
class="flex-1 py-2 text-sm font-medium transition-colors"
|
||||
:style="mobileMapView === 'fiches'
|
||||
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
|
||||
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
|
||||
@click="setMobileView('fiches')"
|
||||
>Fiches</button>
|
||||
</div>
|
||||
|
||||
<div class="lg:hidden flex-1 relative overflow-hidden">
|
||||
<!-- Carte mobile Métropole -->
|
||||
<div v-show="mobileMapView === 'metropole'" class="absolute inset-0">
|
||||
<template #carte>
|
||||
<!-- Métropole -->
|
||||
<div v-show="vf.etat.sousVue === 'metropole'" class="relative flex-1 min-h-0">
|
||||
<ClientOnly>
|
||||
<NavMapV2
|
||||
ref="navMapMobileRef"
|
||||
:structures="metropoleStructures"
|
||||
:selectedId="selectedId"
|
||||
@select-structure="onSelectStructureMobile"
|
||||
:survolId="vf.survolId == null ? null : String(vf.survolId)"
|
||||
:centrage="centrageCarte"
|
||||
@select-structure="onSelectStructure"
|
||||
@survol="(id) => (vf.survolId = id)"
|
||||
/>
|
||||
<template #fallback>
|
||||
<div class="w-full h-full flex items-center justify-center" style="background: var(--nav-bg-alt); color: var(--nav-text-muted);">
|
||||
Chargement de la carte…
|
||||
</div>
|
||||
<div class="w-full h-full flex items-center justify-center" style="background: var(--nav-bg-alt); color: var(--nav-text-muted);">Chargement de la carte…</div>
|
||||
</template>
|
||||
</ClientOnly>
|
||||
</div>
|
||||
|
||||
<!-- Carte mobile Outre-mer -->
|
||||
<div v-show="mobileMapView === 'outremer'" class="absolute inset-0 overflow-y-auto" style="background: var(--nav-bg);">
|
||||
<!-- Outre-mer : pas de structure V2 en DOM-TOM pour l'instant (composant gardé vide) -->
|
||||
<div v-show="vf.etat.sousVue === 'outremer'" class="flex-1 min-h-0 overflow-y-auto" style="background: var(--nav-bg);">
|
||||
<ClientOnly>
|
||||
<OutremerMap
|
||||
:orgs="outremerOrgsLegacy"
|
||||
:selectedId="selectedIdLegacyNum"
|
||||
@select-org="() => {}"
|
||||
/>
|
||||
<OutremerMap :orgs="[]" :selectedId="null" @select-org="() => {}" />
|
||||
<template #fallback>
|
||||
<div class="flex items-center justify-center h-48" style="color: var(--nav-text-muted);">
|
||||
Chargement…
|
||||
</div>
|
||||
<div class="flex items-center justify-center h-48 text-sm" style="color: var(--nav-text-muted);">Chargement…</div>
|
||||
</template>
|
||||
</ClientOnly>
|
||||
</div>
|
||||
|
||||
<!-- Vue graphique mobile -->
|
||||
<div v-show="mobileMapView === 'graphe'" class="absolute inset-0 overflow-hidden" style="background: var(--nav-bg);">
|
||||
<!-- Vue graphique -->
|
||||
<div v-show="vf.etat.sousVue === 'graphe'" class="relative flex-1 min-h-0 overflow-hidden">
|
||||
<ClientOnly>
|
||||
<GraphView
|
||||
:data="bifurcationData"
|
||||
:allHashtags="allHashtags"
|
||||
:active="mobileMapView === 'graphe'"
|
||||
@select-structure="onSelectStructureMobile"
|
||||
:active="vf.etat.sousVue === 'graphe' && vf.vueAffichee !== 'fiches'"
|
||||
@select-structure="onSelectStructure"
|
||||
/>
|
||||
<template #fallback>
|
||||
<div class="flex items-center justify-center h-48" style="color: var(--nav-text-muted);">
|
||||
Chargement du graphe…
|
||||
</div>
|
||||
<div class="flex items-center justify-center h-full" style="color: var(--nav-text-muted);">Chargement du graphe…</div>
|
||||
</template>
|
||||
</ClientOnly>
|
||||
</div>
|
||||
|
||||
<!-- Zone « Fiches » mobile (B3-M3) — pleine hauteur sous la barre d'onglets,
|
||||
pas de carte dessous donc pas de sheet demi-hauteur (même logique que
|
||||
la vue graphe qui masque déjà le MobileSheet). Fix 2026-09-25 : la
|
||||
version précédente empilait un second MobileSheet (état half par
|
||||
défaut) au-dessus d'un fond neutre plein écran, laissant ~45% de
|
||||
hauteur vide au-dessus du panneau. -->
|
||||
<div
|
||||
v-show="mobileMapView === 'fiches'"
|
||||
class="absolute inset-0 flex flex-col overflow-hidden"
|
||||
style="background: var(--nav-bg-alt);"
|
||||
>
|
||||
<FichesPanel
|
||||
:fiches="allStructuresMini"
|
||||
:hashtags="allHashtags"
|
||||
:selected="selectedHashtags"
|
||||
:query="search"
|
||||
intention="Toutes les agences et réseaux du répertoire, filtrables par hashtag."
|
||||
@update:selected="selectedHashtags = $event"
|
||||
@open="(fiche) => onSelectStructureMobile(String(fiche.id))"
|
||||
<!-- Chatbot sous la carte (ordinateur) -->
|
||||
<div v-if="vf.etat.sousVue !== 'outremer'" class="hidden lg:block shrink-0">
|
||||
<ChatbotPlaceholder
|
||||
@highlightOrgs="() => {}"
|
||||
@applyHashtag="(tag) => vf.ajouterFiltre('tags', sansDiese(tag))"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Bottom sheet swipable (masqué en vue graphique et en vue fiches) -->
|
||||
<ClientOnly v-if="mobileMapView !== 'graphe' && mobileMapView !== 'fiches'">
|
||||
<MobileSheet :resultCount="filtered.length" :pending="pending">
|
||||
<!-- Bandeau intention mobile -->
|
||||
<div class="px-3 py-2" style="background: var(--bifurc-banner-bg, #faf8f5); border-bottom: 1px solid var(--bifurc-banner-border, #e0d8cc);">
|
||||
<p class="text-xs leading-relaxed" style="color: var(--bifurc-banner-text, #2c2416); margin: 0;">
|
||||
120 réseaux, collectifs et agences où des pensées écologiques deviennent des pratiques d'architecture.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Filtres hashtags mobile -->
|
||||
<div class="px-3 py-2" style="border-bottom: 1px solid var(--nav-bg-alt);">
|
||||
<HashtagFilter
|
||||
:allHashtags="allHashtags"
|
||||
:selectedHashtags="selectedHashtags"
|
||||
:selectedFamille="selectedFamille"
|
||||
@update:selectedHashtags="selectedHashtags = $event"
|
||||
@update:selectedFamille="selectedFamille = $event"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Barre recherche mobile -->
|
||||
<div class="px-3 pt-2 pb-2" style="border-bottom: 1px solid var(--nav-bg-alt);">
|
||||
<label class="mobile-search-label" aria-label="Rechercher une structure">
|
||||
<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-muted); flex-shrink: 0;">
|
||||
<circle cx="11" cy="11" r="8"/>
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
|
||||
</svg>
|
||||
<input
|
||||
v-model="search"
|
||||
type="search"
|
||||
placeholder="Rechercher…"
|
||||
class="mobile-search-input"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<button
|
||||
v-if="search"
|
||||
type="button"
|
||||
class="mobile-search-clear"
|
||||
aria-label="Effacer"
|
||||
@click.stop="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>
|
||||
<button
|
||||
v-if="hasActiveFilters"
|
||||
@click="resetFilters"
|
||||
class="mt-1 text-xs"
|
||||
style="color: var(--nav-text-muted); text-decoration: underline;"
|
||||
>Effacer les filtres</button>
|
||||
</div>
|
||||
|
||||
<!-- Liste fiches mobile -->
|
||||
<div class="px-3 py-2">
|
||||
<div class="text-xs font-bold uppercase tracking-wide mb-2" style="color: var(--nav-text-muted);">
|
||||
{{ filtered.length }} structure{{ filtered.length > 1 ? 's' : '' }}
|
||||
</div>
|
||||
<div v-if="pending" class="flex items-center justify-center py-8" style="color: var(--nav-text-muted);">
|
||||
Chargement des fiches…
|
||||
</div>
|
||||
<div v-else-if="filtered.length === 0" class="text-center py-8">
|
||||
<p class="text-sm mb-2" style="color: var(--nav-text-muted);">Aucun résultat pour ces filtres.</p>
|
||||
<button @click="resetFilters" class="text-sm underline" style="color: var(--nav-primary-solid);">
|
||||
Effacer les filtres
|
||||
</button>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<div
|
||||
v-for="structure in filtered"
|
||||
:key="structure.id"
|
||||
class="block rounded-lg p-3 transition-all cursor-pointer"
|
||||
:style="selectedId === structure.id
|
||||
? `background: var(--nav-bg-alt); border-left: 3px solid ${familleColor(structure.famille_principale)};`
|
||||
: 'background: var(--nav-surface); border-left: 3px solid transparent;'"
|
||||
@click="onSelectStructureMobile(structure.id)"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<span class="font-semibold text-sm leading-snug" style="color: var(--nav-text);">{{ structure.nom }}</span>
|
||||
<span
|
||||
class="shrink-0 w-2.5 h-2.5 rounded-full mt-1"
|
||||
:style="`background: ${familleColor(structure.famille_principale)};`"
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-0.5 text-xs" style="color: var(--nav-text-muted);">{{ structure.type_principal }} · {{ structure.ville }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</MobileSheet>
|
||||
</ClientOnly>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- ═══════════════════════════════════════ MODAL FICHE V2 (desktop) -->
|
||||
<FicheModalV2
|
||||
v-model="ficheModalOpen"
|
||||
:structureId="ficheModalId"
|
||||
:data="bifurcationData"
|
||||
@update:structureId="ficheModalId = $event"
|
||||
/>
|
||||
<!-- Détail (B12) : le corps de l'ancienne FicheModalV2 ; une structure liée s'ouvre dans le même panneau -->
|
||||
<template #detail="{ fiche, filtrer }">
|
||||
<FicheReseauContenu
|
||||
:structure-id="String(fiche.id)"
|
||||
:data="bifurcationData"
|
||||
:etiquettes-actives="(vf.etat.filtres.tags ?? []).map((t) => `#${t}`)"
|
||||
@etiquette="(tag) => filtrer('tags', sansDiese(tag))"
|
||||
@voisin="(id) => vf.ouvrirFiche(id)"
|
||||
/>
|
||||
</template>
|
||||
</VueFiches>
|
||||
|
||||
<!-- ═══════════════════════════════════════ BOUTON CHATBOT FLOTTANT (mobile) -->
|
||||
<button
|
||||
class="lg:hidden fixed bottom-6 right-4 z-[1000] flex items-center gap-2 px-4 rounded-full shadow-lg"
|
||||
style="
|
||||
class="lg:hidden fixed right-4 z-[1000] flex items-center gap-2 px-4 rounded-full shadow-lg"
|
||||
:style="`
|
||||
bottom: ${basFlottants}px;
|
||||
height: 48px;
|
||||
background: var(--nav-primary);
|
||||
opacity: 0.92;
|
||||
background: var(--nav-primary-solid);
|
||||
color: var(--nav-text-on-primary);
|
||||
box-shadow: 0 4px 16px rgba(26,34,56,0.25);
|
||||
font-family: var(--nav-font);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
"
|
||||
`"
|
||||
aria-label="Ouvrir l'assistant Chatbot"
|
||||
@click="chatbotOpen = true"
|
||||
>
|
||||
@@ -467,6 +108,7 @@
|
||||
<button
|
||||
class="reseaux-info-btn"
|
||||
type="button"
|
||||
:style="vf.largeur === 'telephone' ? `bottom: ${basFlottants}px;` : undefined"
|
||||
@click="missionOpen = true"
|
||||
aria-label="À propos des réseaux AEP cartographiés"
|
||||
title="À propos de cette carte"
|
||||
@@ -490,7 +132,7 @@
|
||||
Cette carte rassemble <strong>120 réseaux, collectifs et agences</strong> qui pratiquent une architecture engagée — écologique, politique, biorégionale. Ce ne sont pas seulement des agences « vertes » : ce sont celles et ceux qui assument des positions, refusent des projets, expérimentent des modèles de gouvernance, mettent leurs ressources et leurs savoirs en commun.
|
||||
</p>
|
||||
<p class="mission-text">
|
||||
Six familles structurent la cartographie : militants, agences engagées, collectifs de production, ressources communes, recherche, formations alternatives. Filtre par hashtag, ouvre la fiche d'une structure, navigue le graphe (3<sup>e</sup> onglet) pour voir les affinités. Si tu animes ou connais un réseau qui devrait y être : <NuxtLink to="/contribuer" @click.stop>propose-le</NuxtLink>.
|
||||
Six familles structurent la cartographie : militants, agences engagées, collectifs de production, ressources communes, recherche, formations alternatives. Filtre par hashtag, ouvre la fiche d'une structure, navigue le graphe (« Vue graphique » dans le sélecteur de carte) pour voir les affinités. Si tu animes ou connais un réseau qui devrait y être : <NuxtLink to="/contribuer" @click.stop>propose-le</NuxtLink>.
|
||||
</p>
|
||||
</MissionPopup>
|
||||
|
||||
@@ -499,73 +141,8 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ReseauxBifurcationData, StructureV2 } from '~/types/structure-v2'
|
||||
|
||||
// ── Couleurs familles ──────────────────────────────────────────────────────
|
||||
const FAMILLE_COLORS: Record<number, string> = {
|
||||
1: '#a85d3e',
|
||||
2: '#c4a472',
|
||||
3: '#d4a017',
|
||||
4: '#5a7a4a',
|
||||
5: '#3d6a8c',
|
||||
6: '#6b3fa0',
|
||||
}
|
||||
|
||||
function familleColor(f: number): string {
|
||||
return FAMILLE_COLORS[f] ?? '#888'
|
||||
}
|
||||
|
||||
// ── État UI ────────────────────────────────────────────────────────────────
|
||||
type MapView = 'metropole' | 'outremer' | 'graphe' | 'fiches'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const selectedId = ref<string | null>(null)
|
||||
const hoveredId = ref<string | null>(null)
|
||||
const ficheModalOpen = ref(false)
|
||||
const ficheModalId = ref<string | null>(null)
|
||||
const chatbotOpen = ref(false)
|
||||
const mobileMapView = ref<MapView>((route.query.vue as string) === 'fiches' ? 'fiches' : 'metropole')
|
||||
const desktopMapView = ref<MapView>((route.query.vue as string) === 'fiches' ? 'fiches' : 'metropole')
|
||||
const missionOpen = ref(false)
|
||||
|
||||
// Sync URL — B3-M3 : pose/retire ?vue=fiches uniquement (agences.vue n'avait
|
||||
// aucune synchronisation URL avant B3 pour search/famille/hashtags/vue ; hors
|
||||
// périmètre de cette mission de l'étendre, seul ?vue=fiches est ajouté ici).
|
||||
function setDesktopView(v: MapView) {
|
||||
desktopMapView.value = v
|
||||
syncViewUrl()
|
||||
}
|
||||
function setMobileView(v: MapView) {
|
||||
mobileMapView.value = v
|
||||
syncViewUrl()
|
||||
}
|
||||
function syncViewUrl() {
|
||||
const q: Record<string, string> = { ...(route.query as Record<string, string>) }
|
||||
if (desktopMapView.value === 'fiches' || mobileMapView.value === 'fiches') {
|
||||
q.vue = 'fiches'
|
||||
} else {
|
||||
delete q.vue
|
||||
}
|
||||
router.replace({ query: Object.keys(q).length ? q : undefined })
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
try {
|
||||
if (!localStorage.getItem('aep_reseaux_seen')) {
|
||||
missionOpen.value = true
|
||||
}
|
||||
} catch {}
|
||||
})
|
||||
|
||||
// Filtres
|
||||
const search = ref('')
|
||||
const selectedFamille = ref<number | null>(null)
|
||||
const selectedHashtags = ref<string[]>([])
|
||||
|
||||
// Refs cartes
|
||||
const navMapRef = ref<any>(null)
|
||||
const navMapMobileRef = ref<any>(null)
|
||||
import type { ConfigVueFiches, ValeurFiltre } from '~/utils/vueFiches'
|
||||
import { sansDiese } from '~/composables/useFicheAdapter'
|
||||
|
||||
// ── Données V2 - JSON statique ─────────────────────────────────────────────
|
||||
const bifurcationData = ref<ReseauxBifurcationData | null>(null)
|
||||
@@ -583,91 +160,98 @@ onMounted(async () => {
|
||||
|
||||
const structures = computed<StructureV2[]>(() => bifurcationData.value?.structures ?? [])
|
||||
|
||||
// Tous les hashtags uniques triés
|
||||
// Tous les hashtags uniques triés (le graphe les attend avec leur « # »)
|
||||
const allHashtags = computed<string[]>(() => {
|
||||
const set = new Set<string>()
|
||||
structures.value.forEach(s => s.hashtags.forEach(h => set.add(h)))
|
||||
return Array.from(set).sort()
|
||||
})
|
||||
|
||||
// ── Filtrage ───────────────────────────────────────────────────────────────
|
||||
const filtered = computed<StructureV2[]>(() => {
|
||||
let result = structures.value
|
||||
// ── Vue fiches (B11) : l'état vit dans l'URL (tags=, famille=, carte=…) ────
|
||||
// Familles : 1-5 = famille principale ou secondaire, 6 = badge « recherche politique »
|
||||
const FAMILLES: ValeurFiltre[] = [
|
||||
{ valeur: '1', libelle: 'Réemploi' },
|
||||
{ valeur: '2', libelle: 'Frugalité' },
|
||||
{ valeur: '3', libelle: 'Social' },
|
||||
{ valeur: '4', libelle: 'Collectifs' },
|
||||
{ valeur: '5', libelle: 'Urbanisme' },
|
||||
{ valeur: '6', libelle: 'Recherche' },
|
||||
]
|
||||
const PAYS: Record<string, string> = {
|
||||
FR: 'France', BE: 'Belgique', DE: 'Allemagne', UK: 'Royaume-Uni', ES: 'Espagne', IT: 'Italie',
|
||||
PT: 'Portugal', CH: 'Suisse', AT: 'Autriche', DK: 'Danemark', PL: 'Pologne', NL: 'Pays-Bas',
|
||||
SE: 'Suède', USA: 'États-Unis', INT: 'International',
|
||||
}
|
||||
|
||||
// Filtre texte
|
||||
if (search.value.trim()) {
|
||||
const q = search.value.toLowerCase()
|
||||
result = result.filter(
|
||||
s =>
|
||||
s.nom.toLowerCase().includes(q) ||
|
||||
s.ville.toLowerCase().includes(q) ||
|
||||
s.description_courte.toLowerCase().includes(q) ||
|
||||
s.hashtags.some(h => h.toLowerCase().includes(q))
|
||||
)
|
||||
}
|
||||
/** Valeurs d'une étiquette, de la plus fréquente à la plus rare (ordre stable des chips et des groupes). */
|
||||
function parFrequence(cle: (s: StructureV2) => string[], libelle: (v: string) => string): ValeurFiltre[] {
|
||||
const n = new Map<string, number>()
|
||||
structures.value.forEach(s => cle(s).forEach(v => n.set(v, (n.get(v) ?? 0) + 1)))
|
||||
return [...n.entries()]
|
||||
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0], 'fr'))
|
||||
.map(([v]) => ({ valeur: v, libelle: libelle(v) }))
|
||||
}
|
||||
|
||||
// Filtre famille - F6 = badge_f6_recherche_politique, pas famille_principale
|
||||
if (selectedFamille.value !== null) {
|
||||
if (selectedFamille.value === 6) {
|
||||
result = result.filter(s => (s.badges as any)?.f6_recherche_politique === true)
|
||||
} else {
|
||||
result = result.filter(
|
||||
s => s.famille_principale === selectedFamille.value ||
|
||||
(s.familles_secondaires ?? []).includes(selectedFamille.value!)
|
||||
)
|
||||
}
|
||||
}
|
||||
const config = computed<ConfigVueFiches>(() => ({
|
||||
dimensions: [
|
||||
{
|
||||
cle: 'tags', libelle: 'Hashtag', variante: 'fonction',
|
||||
valeurs: parFrequence(s => s.hashtags.map(sansDiese), v => `#${v}`),
|
||||
},
|
||||
{ cle: 'famille', libelle: 'Famille', variante: 'echelle', valeurs: FAMILLES },
|
||||
],
|
||||
tris: ['pertinence', 'nom'],
|
||||
groupes: [
|
||||
{ cle: 'famille', libelle: 'Famille', dimension: 'famille', sans: 'Sans famille' },
|
||||
{ cle: 'pays', libelle: 'Pays', dimension: 'pays', sans: 'Pays inconnu', valeurs: parFrequence(s => [s.pays], v => PAYS[v] ?? v) },
|
||||
],
|
||||
etats: ['carte', 'mixte', 'fiches'],
|
||||
sousVues: {
|
||||
parametre: 'carte',
|
||||
defaut: 'metropole',
|
||||
valeurs: [
|
||||
{ valeur: 'metropole', libelle: 'Métropole' },
|
||||
{ valeur: 'outremer', libelle: 'Outre-mer' },
|
||||
{ valeur: 'graphe', libelle: 'Vue graphique' },
|
||||
],
|
||||
},
|
||||
}))
|
||||
|
||||
// Filtre hashtags (AND logique si plusieurs)
|
||||
if (selectedHashtags.value.length) {
|
||||
result = result.filter(
|
||||
s => selectedHashtags.value.every(h => s.hashtags.includes(h))
|
||||
)
|
||||
}
|
||||
const { toMini } = useFicheAdapter()
|
||||
const fichesMini = computed(() => structures.value.map((s) => toMini(s)))
|
||||
const vf = useVueFiches(fichesMini, config)
|
||||
|
||||
return result
|
||||
// La carte ne montre que le résultat courant (recherche + filtres)
|
||||
const metropoleStructures = computed<StructureV2[]>(() => structures.value.filter((s) => vf.idsResultat.has(s.id)))
|
||||
|
||||
// ── Sélection (B12) : la fiche ouverte vit dans l'URL (`fiche=`), détail en panneau ──
|
||||
const selectedId = computed<string | null>(() => vf.ficheId)
|
||||
|
||||
function onSelectStructure(id: string) {
|
||||
vf.ouvrirFiche(id)
|
||||
}
|
||||
|
||||
// « Voir sur la carte » : l'épingle est sur la carte Métropole (pas dans le graphe)
|
||||
const centrageCarte = ref<{ id: string; n: number } | null>(null)
|
||||
watch(() => vf.centrage, (c) => {
|
||||
if (!c) return
|
||||
if (vf.etat.sousVue !== 'metropole') vf.setSousVue('metropole')
|
||||
centrageCarte.value = { id: c.id, n: c.n }
|
||||
})
|
||||
|
||||
const hasActiveFilters = computed(
|
||||
() => !!search.value || selectedFamille.value !== null || selectedHashtags.value.length > 0
|
||||
)
|
||||
// ── Boutons flottants (téléphone) : au-dessus du pied « Voir les N fiches » en état Carte ──
|
||||
const chatbotOpen = ref(false)
|
||||
const missionOpen = ref(false)
|
||||
const basFlottants = computed(() => (vf.largeur === 'telephone' && vf.vueAffichee === 'carte' ? 76 : 24))
|
||||
|
||||
function resetFilters() {
|
||||
search.value = ''
|
||||
selectedFamille.value = null
|
||||
selectedHashtags.value = []
|
||||
}
|
||||
onMounted(() => {
|
||||
try {
|
||||
if (!localStorage.getItem('aep_reseaux_seen')) missionOpen.value = true
|
||||
} catch {}
|
||||
})
|
||||
|
||||
// Structures métropole (pays != DOM-TOM, et avec coordonnées)
|
||||
// Pour simplifier : toutes les structures (la carte gère les sans-coords)
|
||||
const metropoleStructures = computed<StructureV2[]>(() => filtered.value)
|
||||
|
||||
// Panneau « Toutes les fiches » (B3-M3) — mêmes structures que `filtered`,
|
||||
// sans filtrer par géolocalisation (adaptateur mutualisé avec `/`).
|
||||
const { toMini } = useFicheAdapter()
|
||||
const allStructuresMini = computed(() => filtered.value.map((s) => toMini(s)))
|
||||
|
||||
// Outre-mer : pas de structures V2 DOM-TOM pour l'instant - garder le composant existant vide
|
||||
// OutremerMap attend le format Org legacy - on passe un tableau vide
|
||||
const outremerOrgsLegacy = computed(() => [])
|
||||
const selectedIdLegacyNum = computed(() => null)
|
||||
|
||||
// ── Sélection ─────────────────────────────────────────────────────────────
|
||||
function onSelectStructure(id: string) {
|
||||
selectedId.value = selectedId.value === id ? null : id
|
||||
if (typeof window !== 'undefined' && window.innerWidth >= 1024) {
|
||||
ficheModalId.value = id
|
||||
ficheModalOpen.value = true
|
||||
}
|
||||
}
|
||||
|
||||
function onSelectStructureMobile(id: string) {
|
||||
selectedId.value = id
|
||||
ficheModalId.value = id
|
||||
ficheModalOpen.value = true
|
||||
}
|
||||
|
||||
useHead({ title: "AEP - Réseaux de bifurcation architecturale" })
|
||||
// Une fiche ouverte donne son nom à l'onglet (les données arrivent après le rendu serveur ici)
|
||||
useHead({ title: computed(() => (vf.ficheOuverte ? `${vf.ficheOuverte.nom} — AEP` : 'AEP - Réseaux de bifurcation architecturale')) })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -692,6 +276,6 @@ useHead({ title: "AEP - Réseaux de bifurcation architecturale" })
|
||||
.reseaux-info-btn:hover { opacity: 0.85; transform: translateY(-1px); color: var(--nav-text); }
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.reseaux-info-btn { bottom: 16px; left: 340px; }
|
||||
.reseaux-info-btn { bottom: 10px; left: auto; right: 52px; }
|
||||
}
|
||||
</style>
|
||||
|
||||
+155
-588
@@ -1,362 +1,91 @@
|
||||
<template>
|
||||
<div class="flex h-full overflow-hidden" style="background: var(--nav-bg);">
|
||||
<div class="relative h-full overflow-hidden" style="background: var(--nav-bg);">
|
||||
|
||||
<!-- ═══════════════════════════════════════ SIDEBAR DESKTOP (≥ 1024px) -->
|
||||
<div class="hidden lg:flex w-80 shrink-0 flex-col overflow-hidden">
|
||||
<NavSidebar
|
||||
:search="search"
|
||||
:modeValue="territoireMode"
|
||||
:echelle="echelle"
|
||||
:fonctions="fonctions"
|
||||
:territoire="territoire"
|
||||
:echelleCount="echelleCount"
|
||||
:fonctionCount="fonctionCount"
|
||||
:territoireCount="territoireCount"
|
||||
:resultCount="filtered.length"
|
||||
:orgs="filtered"
|
||||
:selectedId="selectedId"
|
||||
:hasActiveFilters="hasActiveFilters"
|
||||
:pending="pending"
|
||||
@update:search="onSearch"
|
||||
@update:mode="onMode"
|
||||
@update:echelle="onEchelle"
|
||||
@update:fonctions="onFonctions"
|
||||
@update:territoire="onTerritoire"
|
||||
@select-org="onSelectOrg"
|
||||
@hover-org="onHoverOrg"
|
||||
@reset-filters="resetFilters"
|
||||
/>
|
||||
<!-- Indicateur source dev -->
|
||||
<div
|
||||
v-if="dataSource === 'seed'"
|
||||
class="absolute bottom-2 left-1/2 -translate-x-1/2 z-[1002] px-2 py-1 rounded text-xs pointer-events-none"
|
||||
style="background: var(--nav-accent); color: var(--nav-text);"
|
||||
>
|
||||
Mode dev — données seed
|
||||
</div>
|
||||
|
||||
<!-- ═══════════════════════════════════════ ZONE CENTRALE (carte) -->
|
||||
<main class="flex-1 flex flex-col overflow-hidden relative">
|
||||
|
||||
<!-- Indicateur source dev -->
|
||||
<div
|
||||
v-if="dataSource === 'seed'"
|
||||
class="absolute top-2 left-2 z-[500] px-2 py-1 rounded text-xs"
|
||||
style="background: var(--nav-accent); color: var(--nav-text);"
|
||||
>
|
||||
Mode dev — données seed
|
||||
</div>
|
||||
|
||||
<!-- ── VUE DESKTOP : Onglets Métropole / Outre-mer / Toutes les fiches ── -->
|
||||
<div class="hidden lg:flex lg:flex-1 lg:flex-col lg:overflow-hidden">
|
||||
<!-- Barre onglets desktop -->
|
||||
<div class="shrink-0 flex" style="background: var(--nav-surface); border-bottom: 1px solid var(--nav-bg-alt);">
|
||||
<button
|
||||
class="px-5 py-2 text-sm font-medium transition-colors"
|
||||
:style="desktopMapView === 'metropole'
|
||||
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
|
||||
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
|
||||
@click="setDesktopView('metropole')"
|
||||
>Métropolitain</button>
|
||||
<button
|
||||
class="px-5 py-2 text-sm font-medium transition-colors"
|
||||
:style="desktopMapView === 'outremer'
|
||||
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
|
||||
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
|
||||
@click="setDesktopView('outremer')"
|
||||
>Outre-mer</button>
|
||||
<button
|
||||
class="px-5 py-2 text-sm font-medium transition-colors"
|
||||
:style="desktopMapView === 'fiches'
|
||||
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
|
||||
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
|
||||
@click="setDesktopView('fiches')"
|
||||
>Toutes les fiches</button>
|
||||
</div>
|
||||
|
||||
<!-- Carte Métropole desktop -->
|
||||
<div v-show="desktopMapView === 'metropole'" class="flex-1 flex flex-col overflow-hidden">
|
||||
<div class="relative flex-1" style="min-height: 200px;">
|
||||
<ClientOnly>
|
||||
<NavMap
|
||||
ref="navMapRef"
|
||||
:orgs="metropoleOrgs"
|
||||
:selectedId="selectedId"
|
||||
@select-org="onSelectOrg"
|
||||
/>
|
||||
<template #fallback>
|
||||
<div class="w-full h-full flex items-center justify-center" style="background: var(--nav-bg-alt); color: var(--nav-text-muted);">Chargement de la carte…</div>
|
||||
</template>
|
||||
</ClientOnly>
|
||||
</div>
|
||||
<ChatbotPlaceholder @highlightOrgs="onHighlightOrgs" />
|
||||
</div>
|
||||
|
||||
<!-- Carte Outre-mer desktop -->
|
||||
<div v-show="desktopMapView === 'outremer'" class="flex-1 flex flex-col overflow-hidden">
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
<ClientOnly>
|
||||
<OutremerMap
|
||||
:orgs="outremerOrgs"
|
||||
:selectedId="selectedId"
|
||||
@select-org="onSelectOrg"
|
||||
/>
|
||||
<template #fallback>
|
||||
<div class="flex items-center justify-center h-full text-sm" style="color: var(--nav-text-muted);">Chargement…</div>
|
||||
</template>
|
||||
</ClientOnly>
|
||||
</div>
|
||||
<ChatbotPlaceholder @highlightOrgs="onHighlightOrgs" />
|
||||
</div>
|
||||
|
||||
<!-- Panneau « Toutes les fiches » desktop (B3-M3) — remplace la carte,
|
||||
fiches sans géoloc incluses (gain principal de cette mission) -->
|
||||
<div v-show="desktopMapView === 'fiches'" class="flex-1 flex flex-col overflow-hidden">
|
||||
<div
|
||||
class="shrink-0 px-4 py-2"
|
||||
style="border-bottom: 1px solid var(--nav-bg-alt); background: var(--nav-surface);"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="text-xs font-medium underline"
|
||||
style="color: var(--nav-text-muted);"
|
||||
@click="setDesktopView('metropole')"
|
||||
>← Carte</button>
|
||||
</div>
|
||||
<FichesPanel
|
||||
:fiches="allFichesMini"
|
||||
:hashtags="[...FONCTIONS]"
|
||||
:selected="fonctions"
|
||||
:query="search"
|
||||
intention="Toutes les structures d'entraide, y compris celles sans adresse sur la carte. Cochez une fonction pour filtrer."
|
||||
@update:selected="onFonctions"
|
||||
@open="(fiche) => onSelectOrg(Number(fiche.id))"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── VUE MOBILE : Onglets Métro/Outre-mer + carte pleine hauteur + sheet swipable ── -->
|
||||
|
||||
<!-- Onglets Métropolitain / Outre-mer / Toutes les fiches -->
|
||||
<div class="lg:hidden shrink-0 flex" style="background: var(--nav-surface); border-bottom: 1px solid var(--nav-bg-alt);">
|
||||
<button
|
||||
class="flex-1 py-2 text-sm font-medium transition-colors"
|
||||
:style="mobileMapView === 'metropole'
|
||||
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
|
||||
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
|
||||
@click="setMobileView('metropole')"
|
||||
>Métropolitain</button>
|
||||
<button
|
||||
class="flex-1 py-2 text-sm font-medium transition-colors"
|
||||
:style="mobileMapView === 'outremer'
|
||||
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
|
||||
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
|
||||
@click="setMobileView('outremer')"
|
||||
>Outre-mer</button>
|
||||
<button
|
||||
class="flex-1 py-2 text-sm font-medium transition-colors"
|
||||
:style="mobileMapView === 'fiches'
|
||||
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
|
||||
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
|
||||
@click="setMobileView('fiches')"
|
||||
>Fiches</button>
|
||||
</div>
|
||||
|
||||
<div class="lg:hidden flex-1 relative overflow-hidden">
|
||||
|
||||
<!-- Carte Métropole -->
|
||||
<div v-show="mobileMapView === 'metropole'" class="absolute inset-0">
|
||||
<!-- ═══════════════════════════════════════ VUE FICHES (B11) : Carte · Mixte · Fiches -->
|
||||
<VueFiches
|
||||
:vf="vf"
|
||||
intention="Les structures d'entraide du métier, y compris celles sans adresse sur la carte."
|
||||
libelle-recherche="Rechercher une structure…"
|
||||
:pending="pending"
|
||||
:selected-id="selectedId"
|
||||
:lien-page="(f) => `/fiche/${f.id}`"
|
||||
>
|
||||
<template #carte>
|
||||
<!-- Métropole -->
|
||||
<div v-show="vf.etat.sousVue === 'metropole'" class="relative flex-1 min-h-0">
|
||||
<ClientOnly>
|
||||
<NavMap
|
||||
ref="navMapMobileRef"
|
||||
:orgs="metropoleOrgs"
|
||||
:selectedId="selectedId"
|
||||
@select-org="onSelectOrgMobile"
|
||||
:survolId="survolNum"
|
||||
:centrage="centrageMetropole"
|
||||
@select-org="onSelectOrg"
|
||||
@survol="(id) => (vf.survolId = id)"
|
||||
/>
|
||||
<template #fallback>
|
||||
<div
|
||||
class="w-full h-full flex items-center justify-center"
|
||||
style="background: var(--nav-bg-alt); color: var(--nav-text-muted);"
|
||||
>
|
||||
Chargement de la carte…
|
||||
</div>
|
||||
<div class="w-full h-full flex items-center justify-center" style="background: var(--nav-bg-alt); color: var(--nav-text-muted);">Chargement de la carte…</div>
|
||||
</template>
|
||||
</ClientOnly>
|
||||
</div>
|
||||
|
||||
<!-- Carte Outre-mer (scroll vertical, pleine largeur) -->
|
||||
<div v-show="mobileMapView === 'outremer'" class="absolute inset-0 overflow-y-auto" style="background: var(--nav-bg);">
|
||||
<!-- Outre-mer -->
|
||||
<div v-show="vf.etat.sousVue === 'outremer'" class="flex-1 min-h-0 overflow-y-auto" style="background: var(--nav-bg);">
|
||||
<ClientOnly>
|
||||
<OutremerMap
|
||||
:orgs="outremerOrgs"
|
||||
:selectedId="selectedId"
|
||||
@select-org="onSelectOrgMobile"
|
||||
@select-org="onSelectOrg"
|
||||
/>
|
||||
<template #fallback>
|
||||
<div class="flex items-center justify-center h-48" style="color: var(--nav-text-muted);">
|
||||
Chargement…
|
||||
</div>
|
||||
<div class="flex items-center justify-center h-48 text-sm" style="color: var(--nav-text-muted);">Chargement…</div>
|
||||
</template>
|
||||
</ClientOnly>
|
||||
</div>
|
||||
|
||||
<!-- Zone « Fiches » mobile (B3-M3) — pleine hauteur sous la barre d'onglets,
|
||||
pas de carte dessous donc pas de sheet demi-hauteur (même logique que
|
||||
la vue graphe qui masque aussi le MobileSheet). Fix 2026-09-25 : la
|
||||
version précédente empilait un second MobileSheet (état half par
|
||||
défaut) au-dessus d'un fond neutre plein écran, laissant ~45% de
|
||||
hauteur vide au-dessus du panneau. -->
|
||||
<div
|
||||
v-show="mobileMapView === 'fiches'"
|
||||
class="absolute inset-0 flex flex-col overflow-hidden"
|
||||
style="background: var(--nav-bg-alt);"
|
||||
>
|
||||
<FichesPanel
|
||||
:fiches="allFichesMini"
|
||||
:hashtags="[...FONCTIONS]"
|
||||
:selected="fonctions"
|
||||
:query="search"
|
||||
intention="Toutes les structures d'entraide, y compris celles sans adresse sur la carte. Cochez une fonction pour filtrer."
|
||||
@update:selected="onFonctions"
|
||||
@open="(fiche) => onSelectOrgMobile(Number(fiche.id))"
|
||||
/>
|
||||
<!-- Chatbot sous la carte (ordinateur) -->
|
||||
<div class="hidden lg:block shrink-0">
|
||||
<ChatbotPlaceholder @highlightOrgs="onHighlightOrgs" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Bottom sheet swipable (Métropole et Outre-mer uniquement) -->
|
||||
<ClientOnly v-if="mobileMapView !== 'fiches'">
|
||||
<MobileSheet :resultCount="filtered.length" :pending="pending">
|
||||
<!-- Barre recherche -->
|
||||
<div class="px-3 pt-2 pb-2" style="border-bottom: 1px solid var(--nav-bg-alt);">
|
||||
<label class="mobile-search-label" aria-label="Rechercher une organisation">
|
||||
<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-muted); flex-shrink: 0;">
|
||||
<circle cx="11" cy="11" r="8"/>
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
|
||||
</svg>
|
||||
<input
|
||||
v-model="mobileSearch"
|
||||
type="search"
|
||||
placeholder="Rechercher…"
|
||||
class="mobile-search-input"
|
||||
autocomplete="off"
|
||||
@input="onSearch(mobileSearch)"
|
||||
/>
|
||||
<button
|
||||
v-if="mobileSearch"
|
||||
type="button"
|
||||
class="mobile-search-clear"
|
||||
aria-label="Effacer"
|
||||
@click.stop="mobileSearch = ''; onSearch('')"
|
||||
>
|
||||
<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>
|
||||
|
||||
<!-- Filtres ÉCHELLE — Chip variant echelle (B3-M1 : alignées sur EchelleFilter.vue,
|
||||
dupliquées ici car la sheet mobile ne passe pas par NavSidebar) -->
|
||||
<div class="mt-2">
|
||||
<span class="text-xs font-bold uppercase tracking-wide block mb-1" style="color: var(--nav-text-muted);">ÉCHELLE</span>
|
||||
<ChipGroup aria-label="Filtrer par échelle">
|
||||
<Chip
|
||||
v-for="opt in ECHELLES"
|
||||
:key="opt"
|
||||
:label="opt"
|
||||
variant="echelle"
|
||||
:active="echelle.includes(opt)"
|
||||
@toggle="toggleEchelle(opt)"
|
||||
/>
|
||||
</ChipGroup>
|
||||
</div>
|
||||
|
||||
<!-- Filtres FONCTION — Chip variant fonction (B3-M1, idem) -->
|
||||
<div class="mt-2">
|
||||
<span class="text-xs font-bold uppercase tracking-wide block mb-1" style="color: var(--nav-text-muted);">FONCTION</span>
|
||||
<ChipGroup aria-label="Filtrer par fonction">
|
||||
<Chip
|
||||
v-for="fn in FONCTIONS"
|
||||
:key="fn"
|
||||
:label="fn"
|
||||
variant="fonction"
|
||||
:active="fonctions.includes(fn)"
|
||||
:count="fonctions.includes(fn) ? (fonctions.indexOf(fn) + 1) : (fonctionCount[fn] ?? 0)"
|
||||
@toggle="toggleFonction(fn)"
|
||||
/>
|
||||
</ChipGroup>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-if="hasActiveFilters"
|
||||
@click="resetFilters"
|
||||
class="mt-2 text-xs"
|
||||
style="color: var(--nav-text-muted); text-decoration: underline;"
|
||||
>✕ Effacer les filtres</button>
|
||||
</div>
|
||||
|
||||
<!-- Compteur + Liste fiches -->
|
||||
<div class="px-3 py-2">
|
||||
<div class="text-xs font-bold uppercase tracking-wide mb-2" style="color: var(--nav-text-muted);">
|
||||
{{ filtered.length }} résultat{{ filtered.length > 1 ? 's' : '' }}
|
||||
</div>
|
||||
<div v-if="pending" class="flex items-center justify-center py-8" style="color: var(--nav-text-muted);">
|
||||
Chargement des fiches…
|
||||
</div>
|
||||
<div v-else-if="filtered.length === 0" class="text-center py-8">
|
||||
<p class="text-sm mb-2" style="color: var(--nav-text-muted);">Aucun résultat pour ces filtres.</p>
|
||||
<button @click="resetFilters" class="text-sm underline" style="color: var(--nav-primary-solid);">
|
||||
Effacer les filtres
|
||||
</button>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<div
|
||||
v-for="org in filtered"
|
||||
:key="org.Id"
|
||||
class="block rounded-lg p-3 transition-all cursor-pointer"
|
||||
:style="selectedId === org.Id
|
||||
? 'background: var(--nav-bg-alt); border-left: 3px solid var(--nav-accent);'
|
||||
: 'background: var(--nav-surface); border-left: 3px solid transparent;'"
|
||||
@click="onSelectOrgMobile(org.Id)"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<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-2 py-0.5 rounded-full text-xs font-medium"
|
||||
style="background: var(--nav-bg-alt); color: var(--nav-text-muted);"
|
||||
>{{ org.echelle }}</span>
|
||||
</div>
|
||||
<div v-if="fonctionsList(org).length" class="mt-1 flex flex-wrap gap-1">
|
||||
<span
|
||||
v-for="fn in fonctionsList(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-1 text-xs" style="color: var(--nav-text-muted);">
|
||||
{{ org.localisation_ville }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</MobileSheet>
|
||||
</ClientOnly>
|
||||
</div>
|
||||
|
||||
</main>
|
||||
|
||||
<!-- ═══════════════════════════════════════ MODAL FICHE (desktop) -->
|
||||
<FicheModal
|
||||
v-model="ficheModalOpen"
|
||||
:orgId="ficheModalId"
|
||||
/>
|
||||
<!-- Détail (B12) : la fiche complète vient de la liste (même enregistrement NocoDB que /api/fiche/:id) -->
|
||||
<template #detail="{ fiche, filtrer }">
|
||||
<template v-if="orgParId.get(String(fiche.id))">
|
||||
<FicheDetail
|
||||
:key="fiche.id"
|
||||
:org="orgParId.get(String(fiche.id))!"
|
||||
compact
|
||||
etiquettes-cliquables
|
||||
@etiquette="(t) => filtrer('fonctions', t)"
|
||||
/>
|
||||
<CommentSection :key="`c-${fiche.id}`" :org-id="Number(fiche.id)" :refresh="commentaires" />
|
||||
<CommentForm :key="`f-${fiche.id}`" :org-id="Number(fiche.id)" @submitted="commentaires++" />
|
||||
</template>
|
||||
</template>
|
||||
</VueFiches>
|
||||
|
||||
<!-- ═══════════════════════════════════════ BOUTON CHATBOT FLOTTANT (mobile) -->
|
||||
<button
|
||||
class="lg:hidden fixed bottom-6 right-4 z-[1000] flex items-center gap-2 px-4 rounded-full shadow-lg"
|
||||
style="
|
||||
class="lg:hidden fixed right-4 z-[1000] flex items-center gap-2 px-4 rounded-full shadow-lg"
|
||||
:style="`
|
||||
bottom: ${basFlottants}px;
|
||||
height: 48px;
|
||||
background: var(--nav-primary);
|
||||
opacity: 0.92;
|
||||
background: var(--nav-primary-solid);
|
||||
color: var(--nav-text-on-primary);
|
||||
box-shadow: 0 4px 16px rgba(26,34,56,0.25);
|
||||
font-family: var(--nav-font);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
"
|
||||
`"
|
||||
aria-label="Ouvrir l'assistant Chatbot"
|
||||
@click="chatbotOpen = true"
|
||||
>
|
||||
@@ -377,6 +106,7 @@
|
||||
<button
|
||||
class="mission-info-btn"
|
||||
type="button"
|
||||
:style="vf.largeur === 'telephone' ? `bottom: ${basFlottants}px;` : undefined"
|
||||
@click="missionOpen = true"
|
||||
aria-label="À propos de cette carte d'entraide"
|
||||
title="À propos de cette carte"
|
||||
@@ -399,291 +129,128 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Org } from '~/types/org'
|
||||
import type { ConfigVueFiches } from '~/utils/vueFiches'
|
||||
import { DOM_TOM } from '~/composables/useFicheAdapter'
|
||||
|
||||
// ── URL query params sync ─────────────────────────────────────────────────
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const search = ref<string>((route.query.q as string) ?? '')
|
||||
const echelle = ref<string[]>(
|
||||
route.query.echelle
|
||||
? (route.query.echelle as string).split(',').filter(Boolean)
|
||||
: []
|
||||
)
|
||||
const fonctions = ref<string[]>(
|
||||
route.query.fonctions
|
||||
? (route.query.fonctions as string).split(',').filter(Boolean)
|
||||
: []
|
||||
)
|
||||
const territoire = ref<string | null>((route.query.territoire as string) ?? null)
|
||||
const territoireMode = ref<string>(
|
||||
(route.query.mode as string) === 'outremer' ? 'outremer' : 'metropole'
|
||||
)
|
||||
// ── Données ───────────────────────────────────────────────────────────────
|
||||
const { data, pending } = await useFetch<{ list: Org[]; source: string }>('/api/organisations')
|
||||
|
||||
type MapView = 'metropole' | 'outremer' | 'fiches'
|
||||
const orgs = computed<Org[]>(() => data.value?.list ?? [])
|
||||
const dataSource = computed(() => data.value?.source ?? 'nocodb')
|
||||
|
||||
const desktopMapView = ref<MapView>((route.query.vue as string) === 'fiches' ? 'fiches' : 'metropole')
|
||||
const selectedId = ref<number | null>(null)
|
||||
const chatbotOpen = ref(false)
|
||||
const ficheModalOpen = ref(false)
|
||||
const ficheModalId = ref<number | null>(null)
|
||||
const mobileMapView = ref<MapView>((route.query.vue as string) === 'fiches' ? 'fiches' : 'metropole')
|
||||
const missionOpen = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
try {
|
||||
if (!localStorage.getItem('aep_mission_seen')) {
|
||||
missionOpen.value = true
|
||||
}
|
||||
} catch {}
|
||||
// `territoire=` (lien existant) : pré-filtre de page, hors de la vue fiches
|
||||
const orgsTerritoire = computed<Org[]>(() => {
|
||||
const t = route.query.territoire
|
||||
return typeof t === 'string' && t ? orgs.value.filter((o) => o.territoire === t) : orgs.value
|
||||
})
|
||||
// Surlignage temporaire (5 sec) suite à une réponse chatbot
|
||||
// → sélectionne le premier ID recommandé sur la carte, puis remet à null
|
||||
|
||||
// ── Vue fiches (B11) : l'état vit dans l'URL ──────────────────────────────
|
||||
const FONCTIONS = ['Juridique', 'Technique', 'Économique', 'Administratif', 'Chantier', 'Comptabilité', 'Développement', 'Formation', "Gestion d'agence", 'Santé mentale']
|
||||
const ECHELLES = ['National', 'Régional', 'Local']
|
||||
const enValeurs = (l: string[]) => l.map((v) => ({ valeur: v, libelle: v }))
|
||||
|
||||
const config: ConfigVueFiches = {
|
||||
dimensions: [
|
||||
{ cle: 'fonctions', libelle: 'Fonction', variante: 'fonction', valeurs: enValeurs(FONCTIONS) },
|
||||
{ cle: 'echelle', libelle: 'Échelle', variante: 'echelle', valeurs: enValeurs(ECHELLES) },
|
||||
],
|
||||
tris: ['pertinence', 'nom', 'recent'],
|
||||
groupes: [
|
||||
{ cle: 'fonction', libelle: 'Fonction', dimension: 'fonctions', sans: 'Sans fonction' },
|
||||
{ cle: 'echelle', libelle: 'Échelle', dimension: 'echelle', sans: 'Sans échelle' },
|
||||
{ cle: 'territoire', libelle: 'Territoire', dimension: 'territoire', sans: 'Sans territoire', valeurs: enValeurs(['Métropole', ...DOM_TOM]) },
|
||||
],
|
||||
etats: ['carte', 'mixte', 'fiches'],
|
||||
sousVues: {
|
||||
parametre: 'mode',
|
||||
defaut: 'metropole',
|
||||
valeurs: [{ valeur: 'metropole', libelle: 'Métropole' }, { valeur: 'outremer', libelle: 'Outre-mer' }],
|
||||
},
|
||||
}
|
||||
|
||||
const { toMini } = useFicheAdapter()
|
||||
const fichesMini = computed(() => orgsTerritoire.value.map((o) => toMini(o)))
|
||||
const vf = useVueFiches(fichesMini, config)
|
||||
|
||||
// La carte ne montre que le résultat courant (recherche + filtres)
|
||||
const orgsFiltres = computed<Org[]>(() => orgsTerritoire.value.filter((o) => vf.idsResultat.has(String(o.Id))))
|
||||
const metropoleOrgs = computed<Org[]>(() => orgsFiltres.value.filter((o) => !o.territoire || !DOM_TOM.includes(o.territoire)))
|
||||
const outremerOrgs = computed<Org[]>(() => orgsFiltres.value.filter((o) => o.territoire && DOM_TOM.includes(o.territoire)))
|
||||
const survolNum = computed(() => (vf.survolId == null ? null : Number(vf.survolId)))
|
||||
|
||||
// ── Sélection (B12) : la fiche ouverte vit dans l'URL (`fiche=`), détail en panneau ──
|
||||
const orgParId = computed(() => new Map(orgs.value.map((o) => [String(o.Id), o])))
|
||||
|
||||
// Épingle et carte-fiche allumées : la fiche ouverte, ou 5 s celle que le chatbot désigne
|
||||
const surligneId = ref<number | null>(null)
|
||||
const selectedId = computed<number | null>(() => surligneId.value ?? (vf.ficheId != null ? Number(vf.ficheId) : null))
|
||||
|
||||
const commentaires = ref(0)
|
||||
|
||||
function onSelectOrg(id: number) {
|
||||
vf.ouvrirFiche(id)
|
||||
}
|
||||
|
||||
// « Voir sur la carte » : la bonne sous-vue (Métropole ou Outre-mer), puis l'épingle
|
||||
const centrageMetropole = ref<{ id: number; n: number } | null>(null)
|
||||
watch(() => vf.centrage, (c) => {
|
||||
if (!c) return
|
||||
const o = orgParId.value.get(c.id)
|
||||
const dom = !!o?.territoire && DOM_TOM.includes(o.territoire)
|
||||
if (vf.etat.sousVue !== (dom ? 'outremer' : 'metropole')) vf.setSousVue(dom ? 'outremer' : 'metropole')
|
||||
if (!dom) centrageMetropole.value = { id: Number(c.id), n: c.n }
|
||||
})
|
||||
|
||||
// Le bouton retour de /fiche/:id (« Ouvrir la page de la fiche ») revient à la même vue
|
||||
if (import.meta.client) {
|
||||
watch(() => route.fullPath, () => {
|
||||
try {
|
||||
sessionStorage.setItem('nav_back_filters', new URLSearchParams(route.query as Record<string, string>).toString())
|
||||
} catch {}
|
||||
}, { immediate: true })
|
||||
}
|
||||
|
||||
// Surlignage temporaire (5 s) suite à une réponse chatbot
|
||||
let highlightTimer: ReturnType<typeof setTimeout> | null = null
|
||||
const prevSelectedId = ref<number | null>(null)
|
||||
|
||||
function onHighlightOrgs(ids: (number | string)[]) {
|
||||
if (!ids.length) return
|
||||
const firstId = typeof ids[0] === 'string' ? parseInt(ids[0], 10) : ids[0]
|
||||
if (isNaN(firstId)) return
|
||||
|
||||
// Sauvegarde la sélection courante
|
||||
prevSelectedId.value = selectedId.value
|
||||
selectedId.value = firstId
|
||||
|
||||
surligneId.value = firstId
|
||||
if (highlightTimer) clearTimeout(highlightTimer)
|
||||
highlightTimer = setTimeout(() => {
|
||||
// Restaure la sélection précédente (ou null)
|
||||
selectedId.value = prevSelectedId.value
|
||||
prevSelectedId.value = null
|
||||
surligneId.value = null
|
||||
highlightTimer = null
|
||||
}, 5000)
|
||||
}
|
||||
|
||||
// Ref locale barre de recherche mobile (synchronisée avec search via onSearch)
|
||||
const mobileSearch = ref<string>((route.query.q as string) ?? '')
|
||||
|
||||
// Refs vers les instances NavMap (desktop + mobile séparées via deux <ClientOnly>)
|
||||
const navMapRef = ref<any>(null)
|
||||
const navMapMobileRef = ref<any>(null)
|
||||
|
||||
// Sync URL <-> état filtres
|
||||
function syncUrl() {
|
||||
const q: Record<string, string> = {}
|
||||
if (search.value) q.q = search.value
|
||||
if (echelle.value.length) q.echelle = echelle.value.join(',')
|
||||
if (fonctions.value.length) q.fonctions = fonctions.value.join(',')
|
||||
if (territoire.value) q.territoire = territoire.value
|
||||
if (territoireMode.value === 'outremer') q.mode = 'outremer'
|
||||
if (desktopMapView.value === 'fiches' || mobileMapView.value === 'fiches') q.vue = 'fiches'
|
||||
router.replace({ query: Object.keys(q).length ? q : undefined })
|
||||
}
|
||||
|
||||
// Changement de vue (carte/fiches) — passe par syncUrl pour poser/retirer ?vue=fiches
|
||||
function setDesktopView(v: MapView) {
|
||||
desktopMapView.value = v
|
||||
syncUrl()
|
||||
}
|
||||
function setMobileView(v: MapView) {
|
||||
mobileMapView.value = v
|
||||
syncUrl()
|
||||
}
|
||||
|
||||
// Sauvegarde les filtres courants dans sessionStorage pour le bouton retour des fiches
|
||||
function storeFiltersForBack() {
|
||||
if (typeof window === 'undefined') return
|
||||
const q: Record<string, string> = {}
|
||||
if (search.value) q.q = search.value
|
||||
if (echelle.value.length) q.echelle = echelle.value.join(',')
|
||||
if (fonctions.value.length) q.fonctions = fonctions.value.join(',')
|
||||
if (territoire.value) q.territoire = territoire.value
|
||||
if (territoireMode.value === 'outremer') q.mode = 'outremer'
|
||||
const qs = new URLSearchParams(q).toString()
|
||||
sessionStorage.setItem('nav_back_filters', qs)
|
||||
}
|
||||
|
||||
function onSearch(v: string) { search.value = v; syncUrl(); storeFiltersForBack() }
|
||||
function onMode(v: string) { territoireMode.value = v; syncUrl(); storeFiltersForBack() }
|
||||
function onEchelle(v: string[]) { echelle.value = v; syncUrl(); storeFiltersForBack() }
|
||||
function onFonctions(v: string[]) { fonctions.value = v; syncUrl(); storeFiltersForBack() }
|
||||
function onTerritoire(v: string | null) { territoire.value = v; syncUrl(); storeFiltersForBack() }
|
||||
|
||||
function onSelectOrg(id: number) {
|
||||
selectedId.value = selectedId.value === id ? null : id
|
||||
// Desktop : ouvrir le modal fiche
|
||||
if (typeof window !== 'undefined' && window.innerWidth >= 1024) {
|
||||
ficheModalId.value = id
|
||||
ficheModalOpen.value = true
|
||||
}
|
||||
}
|
||||
|
||||
// Tap card mobile → ouvre la fiche détaillée
|
||||
function onSelectOrgMobile(id: number) {
|
||||
selectedId.value = id
|
||||
storeFiltersForBack()
|
||||
router.push(`/fiche/${id}`)
|
||||
}
|
||||
|
||||
function onHoverOrg(id: number | null) {
|
||||
if (id !== null) selectedId.value = id
|
||||
}
|
||||
|
||||
const hasActiveFilters = computed(() =>
|
||||
!!search.value || echelle.value.length > 0 || fonctions.value.length > 0 || !!territoire.value
|
||||
)
|
||||
|
||||
function resetFilters() {
|
||||
search.value = ''
|
||||
echelle.value = []
|
||||
fonctions.value = []
|
||||
territoire.value = null
|
||||
router.replace({ query: undefined })
|
||||
}
|
||||
|
||||
// Tagging compact mobile — toggle direct
|
||||
function toggleEchelle(opt: string) {
|
||||
if (echelle.value.includes(opt)) {
|
||||
onEchelle(echelle.value.filter(v => v !== opt))
|
||||
} else {
|
||||
onEchelle([...echelle.value, opt])
|
||||
}
|
||||
}
|
||||
|
||||
function toggleFonction(fn: string) {
|
||||
if (fonctions.value.includes(fn)) {
|
||||
onFonctions(fonctions.value.filter(f => f !== fn))
|
||||
} else {
|
||||
onFonctions([...fonctions.value, fn])
|
||||
}
|
||||
}
|
||||
|
||||
// Sync recherche depuis app.vue top nav (via URL ?q=)
|
||||
watch(() => route.query.q, (v) => {
|
||||
search.value = (v as string) ?? ''
|
||||
})
|
||||
|
||||
// ── Données ───────────────────────────────────────────────────────────────
|
||||
const { data, pending, error } = await useFetch<{ list: Org[]; source: string }>('/api/organisations')
|
||||
|
||||
const orgs = computed<Org[]>(() => data.value?.list ?? [])
|
||||
const dataSource = computed(() => data.value?.source ?? 'nocodb')
|
||||
|
||||
// Fiche aléatoire — réagit au ?random=1
|
||||
watch(() => route.query.random, (v) => {
|
||||
if (v === '1' && orgs.value.length > 0) {
|
||||
const randomOrg = orgs.value[Math.floor(Math.random() * orgs.value.length)]
|
||||
router.replace({ path: `/fiche/${randomOrg.Id}` })
|
||||
}
|
||||
})
|
||||
|
||||
// ── Filtrage côté client ──────────────────────────────────────────────────
|
||||
const filtered = computed<Org[]>(() => {
|
||||
let result = orgs.value
|
||||
|
||||
if (search.value.trim()) {
|
||||
const q = search.value.toLowerCase()
|
||||
result = result.filter(
|
||||
(o) =>
|
||||
o.nom?.toLowerCase().includes(q) ||
|
||||
o.localisation_ville?.toLowerCase().includes(q)
|
||||
)
|
||||
}
|
||||
|
||||
if (echelle.value.length) {
|
||||
result = result.filter((o) => o.echelle && echelle.value.includes(o.echelle))
|
||||
}
|
||||
|
||||
if (fonctions.value.length) {
|
||||
// Garde les orgs qui matchent au moins 1 fonction sélectionnée
|
||||
result = result.filter((o) => {
|
||||
const orgFns = (o.tags_fonction ?? '').split(',').map((f) => f.trim()).filter(Boolean)
|
||||
return fonctions.value.some((fn) => orgFns.includes(fn))
|
||||
})
|
||||
// Tri par score pondéré : priorité 1 (1er cliqué) = poids le plus fort
|
||||
const n = fonctions.value.length
|
||||
const score = (o: Org) =>
|
||||
fonctions.value.reduce((s, fn, i) => {
|
||||
const fns = (o.tags_fonction ?? '').split(',').map((f) => f.trim()).filter(Boolean)
|
||||
return s + (fns.includes(fn) ? (n - i) : 0)
|
||||
}, 0)
|
||||
result = [...result].sort((a, b) => score(b) - score(a))
|
||||
}
|
||||
|
||||
if (territoire.value) {
|
||||
result = result.filter((o) => o.territoire === territoire.value)
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
// Panneau « Toutes les fiches » (B3-M3) — mêmes fiches que `filtered`, sans
|
||||
// filtrer par géolocalisation : c'est le gain principal de cette mission,
|
||||
// les fiches sans latitude/longitude (invisibles sur la carte) y apparaissent.
|
||||
const { toMini } = useFicheAdapter()
|
||||
const allFichesMini = computed(() => filtered.value.map((org) => toMini(org)))
|
||||
|
||||
const DOM_TOM = ['Guadeloupe', 'Martinique', 'Guyane', 'La Réunion', 'Mayotte']
|
||||
const DOM_TOM_LIST = DOM_TOM
|
||||
|
||||
const metropoleOrgs = computed<Org[]>(() =>
|
||||
filtered.value.filter(o => !o.territoire || !DOM_TOM.includes(o.territoire))
|
||||
)
|
||||
|
||||
const outremerOrgs = computed<Org[]>(() => {
|
||||
if (territoire.value && DOM_TOM.includes(territoire.value)) {
|
||||
return filtered.value.filter(o => o.territoire === territoire.value)
|
||||
}
|
||||
return filtered.value.filter(o => o.territoire && DOM_TOM.includes(o.territoire))
|
||||
})
|
||||
|
||||
const outremerCountByDom = computed<Record<string, number>>(() => {
|
||||
const counts: Record<string, number> = {}
|
||||
DOM_TOM.forEach(d => { counts[d] = 0 })
|
||||
filtered.value.forEach(o => {
|
||||
if (o.territoire && DOM_TOM.includes(o.territoire)) {
|
||||
counts[o.territoire] = (counts[o.territoire] ?? 0) + 1
|
||||
// Fiche aléatoire — ?random=1, au chargement comme en navigation interne
|
||||
if (import.meta.client) {
|
||||
watch([() => route.query.random, () => orgs.value.length], ([v, n]) => {
|
||||
if (v === '1' && n > 0) {
|
||||
const randomOrg = orgs.value[Math.floor(Math.random() * n)]
|
||||
router.replace({ path: `/fiche/${randomOrg.Id}` })
|
||||
}
|
||||
})
|
||||
return counts
|
||||
})
|
||||
|
||||
// ── Compteurs ─────────────────────────────────────────────────────────────
|
||||
const ECHELLES = ['National', 'Régional', 'Local'] as const
|
||||
const ECHELLE_LABELS: Record<string, string> = { National: 'Nat', Régional: 'Rég', Local: 'Loc' }
|
||||
const FONCTIONS = ['Juridique', 'Technique', 'Économique', 'Administratif', 'Chantier', 'Comptabilité', 'Développement', 'Formation', "Gestion d'agence", 'Santé mentale'] as const
|
||||
const TERRITOIRES = ['Métropole', 'Guadeloupe', 'Martinique', 'Guyane', 'La Réunion', 'Mayotte']
|
||||
|
||||
const echelleCount = computed<Record<string, number>>(() => {
|
||||
const counts: Record<string, number> = {}
|
||||
ECHELLES.forEach((e) => { counts[e] = 0 })
|
||||
orgs.value.forEach((o) => { if (o.echelle) counts[o.echelle] = (counts[o.echelle] ?? 0) + 1 })
|
||||
return counts
|
||||
})
|
||||
|
||||
const fonctionCount = computed<Record<string, number>>(() => {
|
||||
const counts: Record<string, number> = {}
|
||||
FONCTIONS.forEach((f) => { counts[f] = 0 })
|
||||
orgs.value.forEach((o) => {
|
||||
const fns = (o.tags_fonction ?? '').split(',').map((f) => f.trim()).filter(Boolean)
|
||||
fns.forEach((fn) => { counts[fn] = (counts[fn] ?? 0) + 1 })
|
||||
})
|
||||
return counts
|
||||
})
|
||||
|
||||
const territoireCount = computed<Record<string, number>>(() => {
|
||||
const counts: Record<string, number> = {}
|
||||
TERRITOIRES.forEach((t) => { counts[t] = 0 })
|
||||
orgs.value.forEach((o) => { if (o.territoire) counts[o.territoire] = (counts[o.territoire] ?? 0) + 1 })
|
||||
counts['Métropole'] = orgs.value.filter(o => !o.territoire || !DOM_TOM.includes(o.territoire)).length
|
||||
return counts
|
||||
})
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
function fonctionsList(org: Org): string[] {
|
||||
return (org.tags_fonction ?? '').split(',').map((f) => f.trim()).filter(Boolean).slice(0, 3)
|
||||
}, { immediate: true })
|
||||
}
|
||||
|
||||
useHead({ title: 'AEP — Cartographie de l\'écologie politique architecturale' })
|
||||
// ── Boutons flottants (téléphone) : au-dessus du pied « Voir les N fiches » en état Carte ──
|
||||
const chatbotOpen = ref(false)
|
||||
const missionOpen = ref(false)
|
||||
const basFlottants = computed(() => (vf.largeur === 'telephone' && vf.vueAffichee === 'carte' ? 76 : 24))
|
||||
|
||||
onMounted(() => {
|
||||
try {
|
||||
if (!localStorage.getItem('aep_mission_seen')) missionOpen.value = true
|
||||
} catch {}
|
||||
})
|
||||
|
||||
// Un lien partagé avec fiche= porte le nom de la fiche (onglet, aperçu de lien), dès le rendu serveur
|
||||
useHead({ title: computed(() => (vf.ficheOuverte ? `${vf.ficheOuverte.nom} — AEP` : 'AEP — Cartographie de l\'écologie politique architecturale')) })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -708,6 +275,6 @@ useHead({ title: 'AEP — Cartographie de l\'écologie politique architecturale'
|
||||
.mission-info-btn:hover { opacity: 0.85; transform: translateY(-1px); color: var(--nav-text); }
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.mission-info-btn { bottom: 16px; left: 340px; }
|
||||
.mission-info-btn { bottom: 10px; left: auto; right: 52px; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Tests de la vue fiches (B11-M1) : logique pure de utils/vueFiches.ts et
|
||||
* adaptateurs de composables/useFicheAdapter.ts, chargés tels quels via jiti
|
||||
* (même patron que test-echelles-nav.mjs).
|
||||
* Usage : node scripts/test-vue-fiches.mjs
|
||||
*/
|
||||
|
||||
import { createJiti } from 'jiti'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { dirname, join } from 'node:path'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const jiti = createJiti(import.meta.url)
|
||||
const V = await jiti.import(join(__dirname, '..', 'utils', 'vueFiches.ts'))
|
||||
const A = await jiti.import(join(__dirname, '..', 'composables', 'useFicheAdapter.ts'))
|
||||
|
||||
let n = 0
|
||||
let fail = 0
|
||||
function ok(cond, libelle, detail = '') {
|
||||
n++
|
||||
if (!cond) fail++
|
||||
console.log(`${cond ? 'OK ' : 'FAIL'} ${libelle}${!cond && detail ? ` → ${detail}` : ''}`)
|
||||
}
|
||||
const trie = (o) => (o && typeof o === "object" && !Array.isArray(o) ? Object.fromEntries(Object.keys(o).sort().map((k) => [k, trie(o[k])])) : o)
|
||||
const eq = (a, b) => JSON.stringify(trie(a)) === JSON.stringify(trie(b))
|
||||
|
||||
// ── Données : configuration Entraide, fiches adaptées depuis de vrais Org ──
|
||||
const FONCTIONS = ['Juridique', 'Technique', 'Formation', 'Santé mentale']
|
||||
const cfg = {
|
||||
dimensions: [
|
||||
{ cle: 'fonctions', libelle: 'Fonction', variante: 'fonction', valeurs: FONCTIONS.map((v) => ({ valeur: v, libelle: v })) },
|
||||
{ cle: 'echelle', libelle: 'Échelle', variante: 'echelle', valeurs: ['National', 'Régional', 'Local'].map((v) => ({ valeur: v, libelle: v })) },
|
||||
],
|
||||
tris: ['pertinence', 'nom', 'recent'],
|
||||
groupes: [
|
||||
{ cle: 'fonction', libelle: 'Fonction', dimension: 'fonctions', sans: 'Sans fonction' },
|
||||
{ cle: 'echelle', libelle: 'Échelle', dimension: 'echelle', sans: 'Sans échelle' },
|
||||
{ cle: 'territoire', libelle: 'Territoire', dimension: 'territoire', sans: 'Sans territoire' },
|
||||
],
|
||||
etats: ['carte', 'mixte', 'fiches'],
|
||||
sousVues: { parametre: 'mode', defaut: 'metropole', valeurs: [{ valeur: 'metropole', libelle: 'Métropole' }, { valeur: 'outremer', libelle: 'Outre-mer' }] },
|
||||
}
|
||||
|
||||
const orgs = [
|
||||
{ Id: 1, nom: 'CNOA', echelle: 'National', tags_fonction: 'Juridique', localisation_ville: 'Paris', latitude: 48.8, longitude: 2.3, description: "Ordre des architectes. Deuxième phrase." },
|
||||
{ Id: 2, nom: 'Collectif Fil', echelle: 'Régional', tags_fonction: 'Formation,Juridique', localisation_ville: 'Nantes', latitude: 47.2, longitude: -1.5, description: 'Recherche-action en architecture.' },
|
||||
{ Id: 3, nom: 'Écoute Archi', echelle: 'Local', tags_fonction: 'Santé mentale', localisation_ville: 'Fort-de-France', territoire: 'Martinique', latitude: null, longitude: null, description: 'Soutien psychologique.' },
|
||||
{ Id: 4, nom: 'Bureau Technique', echelle: 'Régional', tags_fonction: 'Technique', localisation_ville: 'Lyon', latitude: 45.7, longitude: 4.8, submitted_at: '2026-09-01T10:00:00Z' },
|
||||
{ Id: 5, nom: 'Atelier sans tag', localisation_ville: 'Rennes', latitude: 48.1, longitude: -1.6 },
|
||||
]
|
||||
const fiches = orgs.map(A.toMini)
|
||||
|
||||
const etat0 = (over = {}) => ({ ...V.lireQuery({}, cfg), ...over })
|
||||
const ids = (fs) => fs.map((f) => f.id)
|
||||
|
||||
// ── Adaptateurs (M2) ──
|
||||
ok(eq(fiches[1].etiquettes, { fonctions: ['Formation', 'Juridique'], echelle: ['Régional'], territoire: ['Métropole'] }), 'org → etiquettes par dimension (fonctions complètes, échelle, territoire)')
|
||||
ok(fiches[2].etiquettes.territoire[0] === 'Martinique' && fiches[2].geoloc === false && fiches[2].coords === undefined, 'org DOM sans coordonnées → territoire Martinique, geoloc false, coords absentes')
|
||||
ok(eq(fiches[0].coords, [48.8, 2.3]) && fiches[0].resume === 'Ordre des architectes.', 'org → coords et résumé (première phrase) inchangé')
|
||||
ok(eq(fiches[1].chips, ['Formation', 'Juridique']) && fiches[1].chipsDimension === 'fonctions' && fiches[1].echelle === 'Régional', 'org → chips et echelle d’affichage inchangés')
|
||||
const structure = A.toMini({
|
||||
id: 'f1-rotor', nom: 'Rotor', url: '', pays: 'BE', ville: 'Bruxelles', famille_principale: 1, familles_secondaires: [3],
|
||||
hashtags: ['#reemploi-structurel', '#plateforme-materiaux'], type_principal: 'Coopérative',
|
||||
badges: { centre_ressources: true, mouvement_manifeste: false, contre_pouvoir_spatial: false, f6_recherche_politique: true },
|
||||
description_courte: 'Réemploi des matériaux de construction.', description_longue: '', pensees: [], sources: [], already_in_v1: false, eligible_v2: true,
|
||||
latitude: 50.8, longitude: 4.3,
|
||||
})
|
||||
ok(eq(structure.etiquettes, { tags: ['reemploi-structurel', 'plateforme-materiaux'], famille: ['1', '3', '6'], pays: ['BE'] }), 'structure → tags sans #, familles principale + secondaires + 6 (badge), pays')
|
||||
ok(eq(structure.chips, ['#reemploi-structurel', '#plateforme-materiaux']) && structure.chipsDimension === 'tags', 'structure → chips d’affichage avec # inchangés')
|
||||
|
||||
// ── URL ↔ état ──
|
||||
const q1 = { vue: 'fiches', q: 'ordre', fonctions: 'Juridique,Formation', echelle: 'Régional', tri: 'nom', groupe: 'fonction', mode: 'outremer', adresse: 'sans' }
|
||||
const e1 = V.lireQuery(q1, cfg)
|
||||
ok(e1.vue === 'fiches' && e1.q === 'ordre' && eq(e1.filtres, { fonctions: ['Juridique', 'Formation'], echelle: ['Régional'] }) && e1.tri === 'nom' && e1.groupe === 'fonction' && e1.sousVue === 'outremer' && e1.sansAdresse, 'lecture : tous les paramètres de la SPEC §5')
|
||||
ok(eq(V.ecrireQuery(e1, cfg), q1), 'aller-retour URL → état → URL identique')
|
||||
const e2 = etat0({ vue: 'mixte', filtres: { fonctions: ['Technique'], echelle: [] }, groupe: 'echelle' })
|
||||
ok(eq(V.lireQuery(V.ecrireQuery(e2, cfg), cfg), e2), 'aller-retour état → URL → état identique')
|
||||
ok(eq(V.ecrireQuery(etat0(), cfg), {}), 'état par défaut → URL vide (sous-vue par défaut non écrite)')
|
||||
ok(eq(V.ecrireQuery(etat0({ q: 'x' }), cfg, { territoire: 'Guyane', random: '1', vue: 'carte', q: 'ancien' }), { territoire: 'Guyane', random: '1', q: 'x' }), 'paramètres non gérés (territoire, random) conservés, gérés remplacés')
|
||||
|
||||
// Rétrocompatibilité et paramètres invalides
|
||||
ok(V.lireQuery({ vue: 'fiches' }, cfg).vue === 'fiches', 'rétrocompat : ?vue=fiches (lien B3 diffusé) → état Fiches')
|
||||
ok(V.lireQuery({ mode: 'outremer' }, cfg).sousVue === 'outremer', 'rétrocompat : ?mode=outremer → sous-vue Outre-mer')
|
||||
ok(V.lireQuery({}, cfg).vue === null, 'sans vue= → null (défaut par largeur)')
|
||||
const inv = V.lireQuery({ vue: 'plein-ecran', tri: 'distance', groupe: 'couleur', mode: 'lune', adresse: 'oui', zzz: '1' }, cfg)
|
||||
ok(inv.vue === null && inv.tri === null && inv.groupe === null && inv.sousVue === 'metropole' && inv.sansAdresse === false, 'paramètres invalides ignorés sans erreur (vue, tri, groupe, mode, adresse)')
|
||||
ok(eq(V.filtresValides({ fonctions: ['Juridique', 'Astrologie'], echelle: ['Mondial'] }, cfg), { fonctions: ['Juridique'], echelle: [] }), 'valeurs de filtre inconnues ignorées')
|
||||
const cfgVide = { ...cfg, dimensions: [{ ...cfg.dimensions[0], valeurs: [] }] }
|
||||
ok(eq(V.filtresValides({ fonctions: ['Astrologie'] }, cfgVide), { fonctions: ['Astrologie'] }), 'dimension pas encore chargée : valeurs gardées (lien partagé non perdu)')
|
||||
ok(eq(V.lireQuery({ fonctions: ['Juridique,Formation', 'x'], q: ['a', 'b'] }, cfg).filtres.fonctions, ['Juridique', 'Formation']), 'paramètre répété : seule la première occurrence est lue')
|
||||
ok(eq(V.lireQuery({ fonctions: ' Juridique ,,Juridique' }, cfg).filtres.fonctions, ['Juridique']), 'espaces, vides et doublons nettoyés')
|
||||
|
||||
// ── Filtrage : OU dans une dimension, ET entre ──
|
||||
const filtre = (over) => ids(V.filtrerFiches(fiches, etat0(over), cfg))
|
||||
ok(eq(filtre({ filtres: { fonctions: ['Juridique', 'Technique'], echelle: [] } }), [1, 2, 4]), 'OU dans une dimension : Juridique OU Technique')
|
||||
ok(eq(filtre({ filtres: { fonctions: ['Juridique', 'Technique'], echelle: ['Régional'] } }), [2, 4]), 'ET entre dimensions : (Juridique OU Technique) ET Régional')
|
||||
ok(eq(filtre({ filtres: { fonctions: [], echelle: ['National', 'Local'] } }), [1, 3]), 'OU sur la dimension secondaire')
|
||||
ok(eq(filtre({ q: 'ecoute' }), [3]), 'recherche insensible aux accents et à la casse (« ecoute » → Écoute)')
|
||||
ok(eq(filtre({ q: 'NANTES' }), [2]), 'recherche sur la ville')
|
||||
ok(eq(filtre({ q: 'psychologique' }), [3]), 'recherche sur le résumé')
|
||||
ok(eq(filtre({ sansAdresse: true }), [3]), 'adresse=sans → seulement les fiches non localisées')
|
||||
|
||||
// ── Compteurs ──
|
||||
const c = V.compterEtiquettes(fiches, etat0({ filtres: { fonctions: ['Juridique'], echelle: ['Régional'] } }), cfg)
|
||||
ok(c.fonctions.Juridique === 1 && c.fonctions.Technique === 1 && c.fonctions.Formation === 1 && c.fonctions['Santé mentale'] === 0, 'compteurs fonction : dans le résultat des AUTRES dimensions (Régional)', JSON.stringify(c.fonctions))
|
||||
ok(c.echelle.National === 1 && c.echelle.Régional === 1 && c.echelle.Local === 0, 'compteurs échelle : dans le résultat des autres dimensions (Juridique)', JSON.stringify(c.echelle))
|
||||
const cq = V.compterEtiquettes(fiches, etat0({ q: 'paris' }), cfg)
|
||||
ok(cq.fonctions.Juridique === 1 && cq.fonctions.Formation === 0, 'compteurs : la recherche s’applique')
|
||||
ok(V.nbFiltresActifs(etat0({ filtres: { fonctions: ['A', 'B'], echelle: ['C'] }, sansAdresse: true })) === 4, 'nombre de filtres actifs (pour « Filtrer · n »)')
|
||||
|
||||
// ── Tri ──
|
||||
ok(V.triEffectif(etat0(), cfg) === 'nom', 'tri par défaut sans filtre : nom')
|
||||
ok(V.triEffectif(etat0({ filtres: { fonctions: ['Juridique'], echelle: [] } }), cfg) === 'pertinence', 'tri par défaut avec filtre : pertinence')
|
||||
ok(V.triEffectif(etat0({ tri: 'recent', filtres: { fonctions: ['Juridique'], echelle: [] } }), cfg) === 'recent', 'tri explicite prioritaire')
|
||||
ok(eq(ids(V.trierFiches(fiches, 'nom', etat0())), [5, 4, 1, 2, 3]), 'tri nom A → Z (« Écoute » rangé à E)')
|
||||
const ePert = etat0({ filtres: { fonctions: ['Juridique', 'Formation'], echelle: [] } })
|
||||
ok(eq(ids(V.trierFiches(V.filtrerFiches(fiches, ePert, cfg), 'pertinence', ePert)), [2, 1]), 'pertinence : la fiche qui coche le plus d’étiquettes d’abord')
|
||||
ok(eq(ids(V.trierFiches(fiches, 'recent', etat0())), [4, 5, 3, 2, 1]), 'récent : date d’abord, puis Id décroissant')
|
||||
ok(V.triEffectif(etat0(), { ...cfg, tris: ['pertinence', 'nom'] }) === 'nom', 'Réseaux AEP (sans récent) : défaut nom')
|
||||
|
||||
// ── Regroupement ──
|
||||
const g = V.regrouper(V.trierFiches(fiches, 'nom', etat0()), cfg.groupes[0], cfg)
|
||||
ok(eq(g.map((x) => `${x.libelle}:${ids(x.fiches).join('+')}`), ['Juridique:1', 'Technique:4', 'Formation:2', 'Santé mentale:3', 'Sans fonction:5']), 'groupe fonction : première étiquette, ordre de la dimension, « sans » en dernier', JSON.stringify(g.map((x) => x.libelle)))
|
||||
ok(g.reduce((s, x) => s + x.fiches.length, 0) === fiches.length, 'regroupement : chaque fiche une seule fois (compteurs justes)')
|
||||
const gt = V.regrouper(fiches, cfg.groupes[2], cfg)
|
||||
ok(eq(gt.map((x) => x.libelle), ['Martinique', 'Métropole']), 'groupe territoire (dimension sans chips) : ordre A → Z')
|
||||
ok(V.regrouper(fiches, null, cfg).length === 1, 'sans regroupement : un seul bloc')
|
||||
ok(eq(ids(V.ordreFiches(g)), [1, 4, 2, 3, 5]), 'ordre fiche à fiche = ordre affiché des groupes (B12)')
|
||||
|
||||
// ── Fiche ouverte : URL, fiche à fiche, historique (B12-M2/M3) ──
|
||||
ok(V.lireFiche({ fiche: '412' }) === '412' && V.lireFiche({ fiche: 'f1-rotor' }) === 'f1-rotor', 'ouverture par URL : fiche= lu (id numérique ou texte)')
|
||||
ok(V.lireFiche({}) === null && V.lireFiche({ fiche: '' }) === null && V.lireFiche({ fiche: ' ' }) === null, 'fiche= absent ou vide → pas de fiche')
|
||||
ok(V.lireFiche({ fiche: ['7', '8'] }) === '7', 'fiche= répété → la première valeur')
|
||||
const qListe = { vue: 'fiches', fonctions: 'Juridique,Formation', tri: 'nom' }
|
||||
ok(eq(V.queryAvecFiche(qListe, 412), { ...qListe, fiche: '412' }), 'queryAvecFiche pose fiche= et garde filtres, tri, vue')
|
||||
ok(eq(V.queryAvecFiche({ ...qListe, fiche: '412' }, null), qListe), 'queryAvecFiche(null) retire fiche= et rien d’autre')
|
||||
const qEcrite = V.ecrireQuery({ ...V.lireQuery({ ...qListe, fiche: '412' }, cfg), tri: 'pertinence' }, cfg, { ...qListe, fiche: '412' })
|
||||
ok(qEcrite.fiche === '412' && qEcrite.tri === 'pertinence', 'un changement de tri garde la fiche ouverte')
|
||||
ok(V.empreinteListe({ ...qListe, fiche: '1' }) === V.empreinteListe({ fiche: '9', tri: 'nom', fonctions: 'Juridique,Formation', vue: 'fiches' }), 'empreinte de la liste : indépendante de fiche= et de l’ordre des clés')
|
||||
|
||||
const ordre = V.ordreFiches(g) // [1, 4, 2, 3, 5]
|
||||
const p = V.positionFiche(ordre, '2')
|
||||
ok(p && p.rang === 3 && p.total === 5 && p.precedente.id === 4 && p.suivante.id === 3, '« 3 / 5 » : précédente et suivante dans l’ordre courant')
|
||||
ok(V.positionFiche(ordre, 1).precedente === null && V.positionFiche(ordre, 5).suivante === null, 'pas de bouclage aux extrémités')
|
||||
const ordreFiltre = V.ordreFiches(V.regrouper(V.filtrerFiches(fiches, etat0({ filtres: { fonctions: ['Juridique'], echelle: [] } }), cfg), null, cfg))
|
||||
ok(V.positionFiche(ordreFiltre, '4') === null, 'fiche hors filtres → position null (« hors de vos filtres », sans précédente/suivante)')
|
||||
ok(V.positionFiche(ordre, null) === null && V.positionFiche(ordre, '999') === null, 'pas de fiche ou id inconnu → null')
|
||||
|
||||
// Historique simulé, avec les mêmes décisions que composables/useVueFiches.ts
|
||||
function navigateur(depart) {
|
||||
const pile = [depart]
|
||||
let i = 0
|
||||
let liste = null // listeALOuverture
|
||||
const cur = () => pile[i]
|
||||
return {
|
||||
cur,
|
||||
longueur: () => i + 1,
|
||||
ouvrir(id) {
|
||||
const mode = V.modeOuverture(V.lireFiche(cur()))
|
||||
if (mode === 'push') { liste = V.empreinteListe(cur()); pile.splice(i + 1); pile.push(V.queryAvecFiche(cur(), id)); i++ }
|
||||
else pile[i] = V.queryAvecFiche(cur(), id)
|
||||
},
|
||||
filtrer(q) { pile[i] = q },
|
||||
fermer() {
|
||||
const mode = V.modeFermeture(liste != null, liste === V.empreinteListe(cur()))
|
||||
liste = null
|
||||
if (mode === 'back') i--
|
||||
else pile[i] = V.queryAvecFiche(cur(), null)
|
||||
},
|
||||
retour() { i--; if (V.lireFiche(cur()) == null) liste = null },
|
||||
}
|
||||
}
|
||||
let nav = navigateur(qListe)
|
||||
nav.ouvrir(1); nav.ouvrir(4); nav.ouvrir(2)
|
||||
ok(nav.longueur() === 2 && nav.cur().fiche === '2', 'ouvrir puis deux fois « suivante » : une seule entrée d’historique ajoutée')
|
||||
nav.retour()
|
||||
ok(nav.longueur() === 1 && V.lireFiche(nav.cur()) === null && eq(nav.cur(), qListe), 'retour arrière (téléphone) : la fiche se ferme, la liste et ses filtres restent')
|
||||
nav = navigateur(qListe)
|
||||
nav.ouvrir(1); nav.fermer()
|
||||
ok(nav.longueur() === 1 && eq(nav.cur(), qListe), 'fermer une fiche ouverte depuis la liste : retour en arrière, historique propre')
|
||||
nav = navigateur({ ...qListe, fiche: '412' })
|
||||
nav.fermer()
|
||||
ok(nav.longueur() === 1 && eq(nav.cur(), qListe), 'lien partagé : fermer retire fiche= sur place, sans quitter la page')
|
||||
nav = navigateur(qListe)
|
||||
nav.ouvrir(1); nav.filtrer({ ...nav.cur(), echelle: 'National' }); nav.fermer()
|
||||
ok(nav.cur().echelle === 'National' && V.lireFiche(nav.cur()) === null, 'filtre changé fiche ouverte : fermer garde le nouveau filtre (pas de retour arrière)')
|
||||
|
||||
console.log(`\n${n - fail}/${n}`)
|
||||
process.exit(fail ? 1 : 0)
|
||||
@@ -89,7 +89,7 @@ export default defineEventHandler(async (event) => {
|
||||
|
||||
const query: string = body.query.trim()
|
||||
const mode: string = body.mode || 'hybrid'
|
||||
const corpus: string = body.corpus || 'both'
|
||||
const corpus: string = body.corpus || 'pensees'
|
||||
const ragUrl: string = (config.ragPeUrl as string) || 'http://localhost:9621'
|
||||
const auteurSlug: string | null = body.auteur_slug?.trim().toLowerCase() || null
|
||||
|
||||
|
||||
@@ -0,0 +1,367 @@
|
||||
/**
|
||||
* vueFiches — B11-M1. Logique pure de la vue fiches (SPEC-vue-fiches §2-§5) :
|
||||
* lecture et écriture de l'URL, filtrage, compteurs, tri, regroupement.
|
||||
* Aucune dépendance à Vue ni au routeur : `composables/useVueFiches.ts`
|
||||
* branche ces fonctions sur la route, `scripts/test-vue-fiches.mjs` les teste.
|
||||
*
|
||||
* Grammaire des filtres : OU dans une dimension, ET entre dimensions.
|
||||
* L'URL est la source de vérité ; un paramètre inconnu ou invalide est ignoré.
|
||||
*/
|
||||
import type { MiniFiche } from '../composables/useFicheAdapter'
|
||||
|
||||
export type EtatVue = 'carte' | 'mixte' | 'fiches'
|
||||
export type TriFiches = 'pertinence' | 'nom' | 'recent'
|
||||
|
||||
export const ETATS_VUE: EtatVue[] = ['carte', 'mixte', 'fiches']
|
||||
export const LIBELLES_TRI: Record<TriFiches, string> = {
|
||||
pertinence: 'Pertinence',
|
||||
nom: 'A → Z',
|
||||
recent: 'Récentes',
|
||||
}
|
||||
|
||||
export interface ValeurFiltre {
|
||||
valeur: string
|
||||
libelle: string
|
||||
}
|
||||
|
||||
export interface DimensionFiltre {
|
||||
/** Paramètre d'URL ET clé de `MiniFiche.etiquettes`. */
|
||||
cle: string
|
||||
libelle: string
|
||||
valeurs: ValeurFiltre[]
|
||||
variante: 'fonction' | 'echelle' | 'posture'
|
||||
}
|
||||
|
||||
export interface GroupeDef {
|
||||
/** Valeur de `groupe=` dans l'URL. */
|
||||
cle: string
|
||||
libelle: string
|
||||
/** Clé de `MiniFiche.etiquettes` : la fiche va sous sa PREMIÈRE étiquette. */
|
||||
dimension: string
|
||||
/** En-tête des fiches qui n'ont aucune étiquette dans cette dimension. */
|
||||
sans: string
|
||||
/** Libellés et ordre des groupes, sinon ceux de la dimension de filtre, sinon A → Z. */
|
||||
valeurs?: ValeurFiltre[]
|
||||
}
|
||||
|
||||
export interface SousVuesCarte {
|
||||
/** Paramètre d'URL : `mode` sur `/` (existant), `carte` sur `/agences`. */
|
||||
parametre: string
|
||||
valeurs: ValeurFiltre[]
|
||||
defaut: string
|
||||
}
|
||||
|
||||
export interface ConfigVueFiches {
|
||||
dimensions: DimensionFiltre[]
|
||||
tris: TriFiches[]
|
||||
groupes: GroupeDef[]
|
||||
etats: EtatVue[]
|
||||
sousVues?: SousVuesCarte
|
||||
}
|
||||
|
||||
export interface EtatFiches {
|
||||
/** null = pas de `vue=` dans l'URL : défaut par largeur (Mixte sur ordinateur, Fiches sur téléphone). */
|
||||
vue: EtatVue | null
|
||||
q: string
|
||||
filtres: Record<string, string[]>
|
||||
/** `adresse=sans` : seulement les fiches sans coordonnées (lien « N sans adresse »). */
|
||||
sansAdresse: boolean
|
||||
/** null = défaut automatique (pertinence dès qu'un filtre est actif, sinon nom). */
|
||||
tri: TriFiches | null
|
||||
/** null = aucun regroupement. */
|
||||
groupe: string | null
|
||||
sousVue: string
|
||||
}
|
||||
|
||||
export interface GroupeRendu {
|
||||
cle: string
|
||||
libelle: string
|
||||
fiches: MiniFiche[]
|
||||
}
|
||||
|
||||
type Query = Record<string, unknown>
|
||||
|
||||
function premiere(v: unknown): string {
|
||||
if (Array.isArray(v)) return typeof v[0] === 'string' ? v[0] : ''
|
||||
return typeof v === 'string' ? v : ''
|
||||
}
|
||||
|
||||
function liste(v: unknown): string[] {
|
||||
const brut = premiere(v)
|
||||
return [...new Set(brut.split(',').map((s) => s.trim()).filter(Boolean))]
|
||||
}
|
||||
|
||||
/** Minuscules, sans accents : « Régional » et « regional » se valent. */
|
||||
export function normaliser(s: string): string {
|
||||
return s.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase()
|
||||
}
|
||||
|
||||
// ── URL ↔ état ─────────────────────────────────────────────────────────────
|
||||
|
||||
export function lireQuery(query: Query, cfg: ConfigVueFiches): EtatFiches {
|
||||
const vueBrute = premiere(query.vue) as EtatVue
|
||||
const triBrut = premiere(query.tri) as TriFiches
|
||||
const groupeBrut = premiere(query.groupe)
|
||||
const sv = cfg.sousVues
|
||||
const svBrute = sv ? premiere(query[sv.parametre]) : ''
|
||||
|
||||
const filtres: Record<string, string[]> = {}
|
||||
for (const d of cfg.dimensions) filtres[d.cle] = liste(query[d.cle])
|
||||
|
||||
return {
|
||||
vue: cfg.etats.includes(vueBrute) ? vueBrute : null,
|
||||
q: premiere(query.q),
|
||||
filtres,
|
||||
sansAdresse: premiere(query.adresse) === 'sans',
|
||||
tri: cfg.tris.includes(triBrut) ? triBrut : null,
|
||||
groupe: cfg.groupes.some((g) => g.cle === groupeBrut) ? groupeBrut : null,
|
||||
sousVue: sv && sv.valeurs.some((v) => v.valeur === svBrute) ? svBrute : (sv?.defaut ?? ''),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Query à écrire pour un état. Les paramètres que la vue fiches ne gère pas
|
||||
* (`territoire`, `random`…) sont recopiés depuis `base`, `fiche` aussi : un
|
||||
* filtre changé garde la fiche ouverte (elle peut passer « hors de vos filtres »).
|
||||
*/
|
||||
export function ecrireQuery(etat: EtatFiches, cfg: ConfigVueFiches, base: Query = {}): Record<string, string> {
|
||||
const geres = new Set(['vue', 'q', 'adresse', 'tri', 'groupe', ...cfg.dimensions.map((d) => d.cle)])
|
||||
if (cfg.sousVues) geres.add(cfg.sousVues.parametre)
|
||||
|
||||
const out: Record<string, string> = {}
|
||||
for (const [k, v] of Object.entries(base)) {
|
||||
if (!geres.has(k) && typeof premiere(v) === 'string' && premiere(v) !== '') out[k] = premiere(v)
|
||||
}
|
||||
if (etat.vue) out.vue = etat.vue
|
||||
if (etat.q.trim()) out.q = etat.q
|
||||
for (const d of cfg.dimensions) {
|
||||
const vals = etat.filtres[d.cle] ?? []
|
||||
if (vals.length) out[d.cle] = vals.join(',')
|
||||
}
|
||||
if (etat.sansAdresse) out.adresse = 'sans'
|
||||
if (etat.tri) out.tri = etat.tri
|
||||
if (etat.groupe) out.groupe = etat.groupe
|
||||
if (cfg.sousVues && etat.sousVue && etat.sousVue !== cfg.sousVues.defaut) {
|
||||
out[cfg.sousVues.parametre] = etat.sousVue
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Ne garde que les valeurs connues de chaque dimension. Une dimension dont la
|
||||
* liste de valeurs est encore vide (données pas chargées) garde tout : le
|
||||
* filtre s'appliquera dès l'arrivée des données, sans perdre le lien partagé.
|
||||
*/
|
||||
export function filtresValides(filtres: Record<string, string[]>, cfg: ConfigVueFiches): Record<string, string[]> {
|
||||
const out: Record<string, string[]> = {}
|
||||
for (const d of cfg.dimensions) {
|
||||
const vals = filtres[d.cle] ?? []
|
||||
out[d.cle] = d.valeurs.length ? vals.filter((v) => d.valeurs.some((x) => x.valeur === v)) : vals
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export function nbFiltresActifs(etat: EtatFiches): number {
|
||||
return Object.values(etat.filtres).reduce((n, v) => n + v.length, 0) + (etat.sansAdresse ? 1 : 0)
|
||||
}
|
||||
|
||||
// ── Filtrage ───────────────────────────────────────────────────────────────
|
||||
|
||||
function porte(f: MiniFiche, dimension: string, valeur: string): boolean {
|
||||
return (f.etiquettes?.[dimension] ?? []).includes(valeur)
|
||||
}
|
||||
|
||||
export function correspondRecherche(f: MiniFiche, q: string): boolean {
|
||||
const n = normaliser(q.trim())
|
||||
if (!n) return true
|
||||
return [f.nom, f.ville, f.resume, f.texte].some((t) => !!t && normaliser(t).includes(n))
|
||||
}
|
||||
|
||||
/**
|
||||
* Fiches qui passent la recherche, `adresse=sans` et les filtres de toutes les
|
||||
* dimensions sauf `ignorer` (sert aux compteurs). OU dans une dimension, ET entre.
|
||||
*/
|
||||
export function filtrerFiches(
|
||||
fiches: MiniFiche[],
|
||||
etat: EtatFiches,
|
||||
cfg: ConfigVueFiches,
|
||||
ignorer?: string,
|
||||
): MiniFiche[] {
|
||||
return fiches.filter((f) => {
|
||||
if (etat.sansAdresse && f.geoloc) return false
|
||||
if (!correspondRecherche(f, etat.q)) return false
|
||||
for (const d of cfg.dimensions) {
|
||||
if (d.cle === ignorer) continue
|
||||
const vals = etat.filtres[d.cle] ?? []
|
||||
if (vals.length && !vals.some((v) => porte(f, d.cle, v))) return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Compteur de chaque chip : fiches qui portent cette étiquette parmi celles que
|
||||
* laissent passer la recherche et les AUTRES dimensions. Une chip active affiche
|
||||
* donc combien de fiches elle apporte, une chip inactive combien on en aurait
|
||||
* en la cochant seule dans sa dimension ; 0 = chip grisée.
|
||||
*/
|
||||
export function compterEtiquettes(
|
||||
fiches: MiniFiche[],
|
||||
etat: EtatFiches,
|
||||
cfg: ConfigVueFiches,
|
||||
): Record<string, Record<string, number>> {
|
||||
const out: Record<string, Record<string, number>> = {}
|
||||
for (const d of cfg.dimensions) {
|
||||
const base = filtrerFiches(fiches, etat, cfg, d.cle)
|
||||
const c: Record<string, number> = {}
|
||||
for (const v of d.valeurs) c[v.valeur] = 0
|
||||
for (const f of base) for (const v of f.etiquettes?.[d.cle] ?? []) if (v in c) c[v]++
|
||||
out[d.cle] = c
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ── Tri ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function triEffectif(etat: EtatFiches, cfg: ConfigVueFiches): TriFiches {
|
||||
if (etat.tri) return etat.tri
|
||||
const filtreEtiquette = Object.values(etat.filtres).some((v) => v.length > 0)
|
||||
if (filtreEtiquette && cfg.tris.includes('pertinence')) return 'pertinence'
|
||||
return cfg.tris.includes('nom') ? 'nom' : cfg.tris[0] ?? 'nom'
|
||||
}
|
||||
|
||||
/** Nombre d'étiquettes cochées que porte la fiche. */
|
||||
export function pertinence(f: MiniFiche, etat: EtatFiches): number {
|
||||
let s = 0
|
||||
for (const [dim, vals] of Object.entries(etat.filtres)) for (const v of vals) if (porte(f, dim, v)) s++
|
||||
return s
|
||||
}
|
||||
|
||||
const collator = new Intl.Collator('fr', { sensitivity: 'base', numeric: true })
|
||||
|
||||
function parNom(a: MiniFiche, b: MiniFiche): number {
|
||||
return collator.compare(a.nom ?? '', b.nom ?? '')
|
||||
}
|
||||
|
||||
function idNum(f: MiniFiche): number {
|
||||
const n = Number(f.id)
|
||||
return Number.isFinite(n) ? n : -Infinity
|
||||
}
|
||||
|
||||
export function trierFiches(fiches: MiniFiche[], tri: TriFiches, etat: EtatFiches): MiniFiche[] {
|
||||
const copie = [...fiches]
|
||||
if (tri === 'nom') return copie.sort(parNom)
|
||||
if (tri === 'pertinence') {
|
||||
const score = new Map(copie.map((f) => [f, pertinence(f, etat)]))
|
||||
return copie.sort((a, b) => score.get(b)! - score.get(a)! || parNom(a, b))
|
||||
}
|
||||
// récent : date décroissante, puis Id décroissant (Entraide : Id = ordre d'arrivée)
|
||||
return copie.sort((a, b) => {
|
||||
if (a.date && b.date && a.date !== b.date) return a.date < b.date ? 1 : -1
|
||||
if (!!a.date !== !!b.date) return a.date ? -1 : 1
|
||||
return idNum(b) - idNum(a) || parNom(a, b)
|
||||
})
|
||||
}
|
||||
|
||||
// ── Regroupement ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Une fiche va sous sa PREMIÈRE étiquette de la dimension (sinon le compteur
|
||||
* de groupe ment). Ordre des groupes : celui de `def.valeurs`, sinon de la
|
||||
* dimension de filtre, sinon A → Z ; « sans » en dernier. L'ordre des fiches
|
||||
* dans chaque groupe est celui reçu (déjà trié).
|
||||
*/
|
||||
export function regrouper(fiches: MiniFiche[], def: GroupeDef | null, cfg: ConfigVueFiches): GroupeRendu[] {
|
||||
if (!def) return [{ cle: '', libelle: '', fiches }]
|
||||
const ref = def.valeurs ?? cfg.dimensions.find((d) => d.cle === def.dimension)?.valeurs ?? []
|
||||
const libelle = (v: string) => ref.find((x) => x.valeur === v)?.libelle ?? v
|
||||
|
||||
const parCle = new Map<string, MiniFiche[]>()
|
||||
for (const f of fiches) {
|
||||
const cle = f.etiquettes?.[def.dimension]?.[0] ?? ''
|
||||
if (!parCle.has(cle)) parCle.set(cle, [])
|
||||
parCle.get(cle)!.push(f)
|
||||
}
|
||||
const rang = (cle: string) => {
|
||||
if (cle === '') return Number.MAX_SAFE_INTEGER
|
||||
const i = ref.findIndex((x) => x.valeur === cle)
|
||||
return i === -1 ? ref.length : i
|
||||
}
|
||||
return [...parCle.keys()]
|
||||
.sort((a, b) => rang(a) - rang(b) || collator.compare(libelle(a), libelle(b)))
|
||||
.map((cle) => ({ cle, libelle: cle === '' ? def.sans : libelle(cle), fiches: parCle.get(cle)! }))
|
||||
}
|
||||
|
||||
/** Ordre de lecture courant (filtres + tri + regroupement) : le fiche à fiche de B12 le suit. */
|
||||
export function ordreFiches(groupes: GroupeRendu[]): MiniFiche[] {
|
||||
return groupes.flatMap((g) => g.fiches)
|
||||
}
|
||||
|
||||
// ── Fiche ouverte (B12) ────────────────────────────────────────────────────
|
||||
|
||||
/** `fiche=` de l'URL ; null si absent ou vide. L'id reste une chaîne (Entraide : nombre, Réseaux : `f1-rotor`). */
|
||||
export function lireFiche(query: Query): string | null {
|
||||
const v = premiere(query.fiche).trim()
|
||||
return v || null
|
||||
}
|
||||
|
||||
/** Query avec `fiche=` posé (id) ou retiré (null), tout le reste recopié tel quel. */
|
||||
export function queryAvecFiche(query: Query, id: string | number | null): Record<string, string> {
|
||||
const out: Record<string, string> = {}
|
||||
for (const [k, v] of Object.entries(query)) {
|
||||
if (k !== 'fiche' && premiere(v) !== '') out[k] = premiere(v)
|
||||
}
|
||||
if (id != null && String(id) !== '') out.fiche = String(id)
|
||||
return out
|
||||
}
|
||||
|
||||
export interface PositionFiche {
|
||||
/** Rang dans l'ordre courant, de 1 à total. */
|
||||
rang: number
|
||||
total: number
|
||||
precedente: MiniFiche | null
|
||||
suivante: MiniFiche | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Place de la fiche dans l'ordre courant (filtres + tri + regroupement).
|
||||
* null = fiche hors de l'ordre courant (« hors de vos filtres ») : pas de
|
||||
* précédente ni de suivante. Pas de bouclage aux extrémités.
|
||||
*/
|
||||
export function positionFiche(ordre: MiniFiche[], id: string | number | null): PositionFiche | null {
|
||||
if (id == null) return null
|
||||
const i = ordre.findIndex((f) => String(f.id) === String(id))
|
||||
if (i === -1) return null
|
||||
return {
|
||||
rang: i + 1,
|
||||
total: ordre.length,
|
||||
precedente: i > 0 ? ordre[i - 1] : null,
|
||||
suivante: i < ordre.length - 1 ? ordre[i + 1] : null,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Historique (SPEC §5) : OUVRIR une fiche depuis la liste ajoute une entrée
|
||||
* (le retour du téléphone la referme) ; passer d'une fiche à l'autre la
|
||||
* remplace (le retour ne rejoue pas les vingt fiches parcourues).
|
||||
*/
|
||||
export function modeOuverture(ficheCourante: string | null): 'push' | 'replace' {
|
||||
return ficheCourante == null ? 'push' : 'replace'
|
||||
}
|
||||
|
||||
/**
|
||||
* FERMER : si l'entrée a été ajoutée par cette page ET que la liste derrière
|
||||
* n'a pas changé depuis (mêmes filtres, tri, vue), on revient en arrière :
|
||||
* l'historique reste propre. Sinon (lien partagé, rechargement, filtre changé
|
||||
* fiche ouverte), on retire `fiche=` sur place : un retour arrière aurait
|
||||
* défait les filtres choisis entre-temps.
|
||||
*/
|
||||
export function modeFermeture(ouverteParLaPage: boolean, listeInchangee: boolean): 'back' | 'replace' {
|
||||
return ouverteParLaPage && listeInchangee ? 'back' : 'replace'
|
||||
}
|
||||
|
||||
/** Empreinte de la liste derrière la fiche : la query sans `fiche=`, clés triées. */
|
||||
export function empreinteListe(query: Query): string {
|
||||
const q = queryAvecFiche(query, null)
|
||||
return Object.keys(q).sort().map((k) => `${k}=${q[k]}`).join('&')
|
||||
}
|
||||
Reference in New Issue
Block a user