Compare commits
6
Commits
55027ba482
...
1054172ec9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1054172ec9 | ||
|
|
a9befb25ab | ||
|
|
a6dcf48c71 | ||
|
|
5e0027b861 | ||
|
|
1bb55538a4 | ||
|
|
fe1043e321 |
+12
-7
@@ -58,7 +58,7 @@ Pour une chip non cliquable (affichage seul, ex. liste de résultats) : `as="spa
|
||||
|
||||
`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.
|
||||
|
||||
## Vue fiches (B11, 02/10/2026) — `VueFiches` + `useVueFiches`
|
||||
## Vue fiches (B11 02/10/2026, détail B12 03/10/2026) — `VueFiches` + `useVueFiches`
|
||||
|
||||
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).
|
||||
|
||||
@@ -66,11 +66,11 @@ Remplace `FichesPanel`, `NavSidebar`, `MobileSheet` et les onglets Métropolitai
|
||||
|
||||
| Fichier | Rôle | Testé par |
|
||||
|---|---|---|
|
||||
| `utils/vueFiches.ts` | logique pure : `lireQuery` / `ecrireQuery`, `filtrerFiches`, `compterEtiquettes`, `trierFiches`, `regrouper`, `ordreFiches` | `node scripts/test-vue-fiches.mjs` |
|
||||
| `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 » | navigateur |
|
||||
| `components/VueFiches.vue` (+ `VueFichesFiltres.vue`) | barre d'outils, trois états, filtres, grille, poignée, feuille « Filtrer », panneau de détail (B12) | navigateur |
|
||||
|
||||
**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`, le futur `fiche=` de B12) est recopié tel quel. La recherche part dans l'URL 250 ms après la dernière touche.
|
||||
**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.
|
||||
|
||||
**É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`.
|
||||
|
||||
@@ -78,22 +78,27 @@ Remplace `FichesPanel`, `NavSidebar`, `MobileSheet` et les onglets Métropolitai
|
||||
|
||||
**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"
|
||||
:selected-id="selectedId" @select="(fiche) => ouvrir(fiche)" @survol="…">
|
||||
: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>
|
||||
```
|
||||
|
||||
`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.ordre` pour le fiche à fiche (B12), `vf.largeur` et `vf.vueAffichee` pour placer ses boutons flottants.
|
||||
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` ; `@select` garde le comportement d'ouverture propre à la page (modale, page `/fiche/:id`…).
|
||||
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.
|
||||
|
||||
+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,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>
|
||||
@@ -26,6 +26,8 @@ const props = defineProps<{
|
||||
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<{
|
||||
@@ -275,4 +277,20 @@ onUnmounted(() => {
|
||||
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>
|
||||
|
||||
@@ -23,6 +23,8 @@ const props = defineProps<{
|
||||
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<{
|
||||
@@ -272,4 +274,20 @@ onUnmounted(() => {
|
||||
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>
|
||||
|
||||
+329
-7
@@ -11,9 +11,9 @@
|
||||
Contrat complet : DESIGN-SYSTEM.md §Vue fiches.
|
||||
-->
|
||||
<template>
|
||||
<div class="vf" :class="`vf--${vf.vueAffichee}`">
|
||||
<div class="vf" :class="[`vf--${vf.vueAffichee}`, { 'vf--detail': vf.ficheId != null }]">
|
||||
<!-- ═══════════ Barre d'outils -->
|
||||
<div class="vf-barre">
|
||||
<div class="vf-barre" :inert="detailCouvrant || undefined">
|
||||
<div class="vf-seg" role="group" aria-label="Affichage">
|
||||
<button
|
||||
v-for="e in etatsBoutons"
|
||||
@@ -100,7 +100,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Téléphone, état Fiches : les pastilles actives sous la barre (les chips sont dans la feuille) -->
|
||||
<div v-if="pastilles.length" class="vf-pastilles vf-pastilles--tel">
|
||||
<div v-if="pastilles.length" class="vf-pastilles vf-pastilles--tel" :inert="detailCouvrant || undefined">
|
||||
<Chip
|
||||
v-for="p in pastilles"
|
||||
:key="p.cle"
|
||||
@@ -116,9 +116,11 @@
|
||||
<!-- ═══════════ Corps : liste | carte -->
|
||||
<div class="vf-corps">
|
||||
<section
|
||||
ref="liste"
|
||||
class="vf-liste"
|
||||
:aria-label="`Liste des fiches`"
|
||||
:aria-hidden="vf.vueAffichee === 'carte' ? 'true' : undefined"
|
||||
:aria-hidden="vf.vueAffichee === 'carte' || detailCouvrant ? 'true' : undefined"
|
||||
:inert="detailCouvrant || undefined"
|
||||
>
|
||||
<p v-if="intention" class="vf-intention">{{ intention }}</p>
|
||||
|
||||
@@ -154,11 +156,12 @@
|
||||
<FicheMiniCard
|
||||
v-for="f in g.fiches"
|
||||
:key="`${f.kind}-${f.id}`"
|
||||
:data-fiche-id="String(f.id)"
|
||||
:fiche="f"
|
||||
etiquettes-cliquables
|
||||
:etiquettes-actives="libellesActifs"
|
||||
:actif="String(f.id) === String(vf.survolId ?? '') || String(f.id) === String(selectedId ?? '')"
|
||||
@open="emit('select', f)"
|
||||
:actif="String(f.id) === String(vf.survolId ?? '') || String(f.id) === (vf.ficheId ?? String(selectedId ?? ''))"
|
||||
@open="ouvrir(f)"
|
||||
@etiquette="(l) => ajouterDepuisCarte(f, l)"
|
||||
@mouseenter="survoler(f)"
|
||||
@mouseleave="survoler(null)"
|
||||
@@ -168,7 +171,7 @@
|
||||
</template>
|
||||
</section>
|
||||
|
||||
<div class="vf-carte">
|
||||
<div class="vf-carte" :inert="detailCouvrant || undefined">
|
||||
<button
|
||||
type="button"
|
||||
class="vf-poignee"
|
||||
@@ -190,6 +193,71 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════ Détail (B12) : panneau à droite (≥ 768), plein écran au téléphone -->
|
||||
<aside
|
||||
v-if="vf.ficheId != null"
|
||||
ref="detail"
|
||||
class="vf-detail"
|
||||
tabindex="-1"
|
||||
:aria-label="vf.ficheOuverte ? `Fiche : ${vf.ficheOuverte.nom}` : 'Fiche'"
|
||||
@keydown="clavierDetail"
|
||||
@touchstart.passive="debutBalayage"
|
||||
@touchend="finBalayage"
|
||||
>
|
||||
<div class="vf-detail__tete">
|
||||
<button
|
||||
type="button"
|
||||
class="vf-detail__fermer"
|
||||
:aria-label="`Fermer la fiche et revenir aux ${vf.resultat.length} fiches`"
|
||||
@click="vf.fermerFiche()"
|
||||
>
|
||||
<span class="vf-detail__fermer-tel" aria-hidden="true">‹ {{ vf.resultat.length }} {{ vf.resultat.length > 1 ? 'fiches' : 'fiche' }}</span>
|
||||
<span class="vf-detail__fermer-ordi" aria-hidden="true">✕</span>
|
||||
</button>
|
||||
<span class="vf-detail__espace" aria-hidden="true" />
|
||||
<button
|
||||
v-if="vf.ficheOuverte"
|
||||
type="button"
|
||||
class="vf-detail__partager"
|
||||
aria-label="Copier le lien de cette fiche"
|
||||
@click="copierLien"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" /><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Fiche à fiche : en tête sur ordinateur, en pied au téléphone (même élément, ordre CSS) -->
|
||||
<nav v-if="vf.position" class="vf-detail__nav" aria-label="Fiche à fiche">
|
||||
<button type="button" class="vf-detail__pas" :disabled="!vf.position.precedente" @click="vf.fichePrecedente()">
|
||||
<span aria-hidden="true">‹ </span>Précédente
|
||||
</button>
|
||||
<span class="vf-detail__rang" aria-live="polite">
|
||||
<span class="vf-sr">Fiche </span>{{ vf.position.rang }}<span class="vf-sr"> sur </span><span aria-hidden="true"> / </span>{{ vf.position.total }}
|
||||
</span>
|
||||
<button type="button" class="vf-detail__pas" :disabled="!vf.position.suivante" @click="vf.ficheSuivante()">
|
||||
Suivante<span aria-hidden="true"> ›</span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<div ref="corpsDetail" class="vf-detail__corps">
|
||||
<p v-if="vf.ficheOuverte && !vf.position" class="vf-detail__hors">
|
||||
Hors de vos filtres : cette fiche n'est pas dans la liste affichée.
|
||||
</p>
|
||||
|
||||
<template v-if="vf.ficheOuverte">
|
||||
<div class="vf-detail__actions">
|
||||
<a v-if="lienPageFiche" :href="lienPageFiche" class="vf-lien">Ouvrir la page de la fiche</a>
|
||||
<button v-if="peutCentrer" type="button" class="vf-lien" @click="vf.voirSurCarte(vf.ficheOuverte.id)">Voir sur la carte</button>
|
||||
<button type="button" class="vf-lien" @click="copierLien">Copier le lien</button>
|
||||
<span class="vf-detail__statut" role="status">{{ statutLien }}</span>
|
||||
</div>
|
||||
<slot name="detail" :fiche="vf.ficheOuverte" :filtrer="filtrerDepuisDetail" />
|
||||
<p class="vf-detail__aide">← → pour passer d'une fiche à l'autre · Échap pour fermer</p>
|
||||
</template>
|
||||
<p v-else class="vf-etat-vide">{{ pending ? 'Chargement de la fiche…' : 'Fiche introuvable : elle a peut-être été retirée.' }}</p>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════ Feuille « Filtrer » (téléphone) -->
|
||||
@@ -241,6 +309,8 @@ const props = defineProps<{
|
||||
libelleRecherche: string
|
||||
pending?: boolean
|
||||
selectedId?: string | number | null
|
||||
/** Page propre de la fiche (`/fiche/:id` pour l'Entraide) ; null = pas de page, le lien partagé est l'URL avec `fiche=`. */
|
||||
lienPage?: (f: MiniFiche) => string | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -336,6 +406,106 @@ function pieger(e: KeyboardEvent) {
|
||||
}
|
||||
// La feuille n'existe que sous 768 px : elle se ferme si l'écran s'élargit.
|
||||
watch(() => props.vf.largeur, (l) => { if (l && l !== 'telephone') feuilleOuverte.value = false })
|
||||
|
||||
// ═══════════ Détail et fiche à fiche (B12) ═══════════
|
||||
const liste = ref<HTMLElement | null>(null)
|
||||
const detail = ref<HTMLElement | null>(null)
|
||||
const corpsDetail = ref<HTMLElement | null>(null)
|
||||
|
||||
/** Téléphone : le détail couvre la vue, le reste sort du clavier et des lecteurs d'écran. */
|
||||
const detailCouvrant = computed(() => props.vf.ficheId != null && props.vf.largeur === 'telephone')
|
||||
|
||||
const lienPageFiche = computed(() => (props.vf.ficheOuverte && props.lienPage ? props.lienPage(props.vf.ficheOuverte) : null))
|
||||
/** « Voir sur la carte » : la fiche a une épingle, et l'épingle est sur la carte (la carte ne montre que le résultat). */
|
||||
const peutCentrer = computed(() => {
|
||||
const f = props.vf.ficheOuverte
|
||||
return !!f && f.geoloc && props.vf.cfg.etats.includes('carte') && props.vf.idsResultat.has(String(f.id))
|
||||
})
|
||||
|
||||
function ouvrir(f: MiniFiche) {
|
||||
props.vf.ouvrirFiche(f.id)
|
||||
emit('select', f)
|
||||
}
|
||||
|
||||
/** Étiquette cliquée dans le détail : filtre, et au téléphone referme la fiche (on veut voir la liste filtrée). */
|
||||
function filtrerDepuisDetail(dim: string, valeur: string) {
|
||||
props.vf.filtrerDepuisFiche(dim, valeur, props.vf.largeur === 'telephone')
|
||||
}
|
||||
|
||||
// Ouverture : on retient la position de défilement de la liste et la carte-fiche d'origine.
|
||||
let defilementListe = 0
|
||||
let origineId: string | null = null
|
||||
watch(() => props.vf.ficheId, (id, avant) => {
|
||||
if (!import.meta.client) return
|
||||
if (id != null && avant == null) {
|
||||
defilementListe = liste.value?.scrollTop ?? 0
|
||||
origineId = id
|
||||
nextTick(() => detail.value?.focus({ preventScroll: true }))
|
||||
} else if (id != null && avant != null) {
|
||||
// Fiche suivante : le corps repart en haut ; si le bouton pressé est devenu inactif (bout de liste), le focus reste dans le détail.
|
||||
nextTick(() => {
|
||||
corpsDetail.value?.scrollTo({ top: 0 })
|
||||
if (!detail.value?.contains(document.activeElement)) detail.value?.focus({ preventScroll: true })
|
||||
})
|
||||
} else if (id == null && avant != null) {
|
||||
// Fermeture : même position de défilement, focus sur la carte-fiche de la dernière fiche lue (sinon celle d'origine).
|
||||
nextTick(() => {
|
||||
if (liste.value) liste.value.scrollTop = defilementListe
|
||||
const cible = [avant, origineId].map((x) => x && liste.value?.querySelector<HTMLElement>(`[data-fiche-id="${CSS.escape(x)}"] .fiche-mini-card__ouvrir`)).find(Boolean)
|
||||
if (cible) { cible.focus({ preventScroll: true }); cible.scrollIntoView({ block: 'nearest' }) }
|
||||
origineId = null
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
function clavierDetail(e: KeyboardEvent) {
|
||||
const t = e.target as HTMLElement
|
||||
const champ = !!t.closest('input, textarea, select, [contenteditable="true"]')
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
// Échap dans un champ (commentaire, signalement) le quitte d'abord : un brouillon ne se perd pas d'une touche
|
||||
if (champ) { detail.value?.focus({ preventScroll: true }); return }
|
||||
props.vf.fermerFiche()
|
||||
return
|
||||
}
|
||||
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return
|
||||
if (e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return
|
||||
if (champ) return
|
||||
e.preventDefault()
|
||||
if (e.key === 'ArrowLeft') props.vf.fichePrecedente()
|
||||
else props.vf.ficheSuivante()
|
||||
}
|
||||
|
||||
// Balayage horizontal, au téléphone, dans le détail seulement (la carte garde le doigt).
|
||||
let toucheX = 0
|
||||
let toucheY = 0
|
||||
function debutBalayage(e: TouchEvent) {
|
||||
toucheX = e.changedTouches[0].clientX
|
||||
toucheY = e.changedTouches[0].clientY
|
||||
}
|
||||
function finBalayage(e: TouchEvent) {
|
||||
if (props.vf.largeur !== 'telephone') return
|
||||
const dx = e.changedTouches[0].clientX - toucheX
|
||||
const dy = e.changedTouches[0].clientY - toucheY
|
||||
if (Math.abs(dx) < 60 || Math.abs(dx) < 1.5 * Math.abs(dy)) return
|
||||
if (dx < 0) props.vf.ficheSuivante()
|
||||
else props.vf.fichePrecedente()
|
||||
}
|
||||
|
||||
// « Copier le lien » : l'URL courante dit tout (filtres, tri, vue, fiche).
|
||||
const statutLien = ref('')
|
||||
let minuteurLien: ReturnType<typeof setTimeout> | null = null
|
||||
async function copierLien() {
|
||||
const url = window.location.href
|
||||
try {
|
||||
await navigator.clipboard.writeText(url)
|
||||
statutLien.value = 'Lien copié.'
|
||||
} catch {
|
||||
statutLien.value = `Copie impossible ici : ${url}`
|
||||
}
|
||||
if (minuteurLien) clearTimeout(minuteurLien)
|
||||
minuteurLien = setTimeout(() => { statutLien.value = '' }, 4000)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -811,4 +981,156 @@ watch(() => props.vf.largeur, (l) => { if (l && l !== 'telephone') feuilleOuvert
|
||||
outline: 2px solid var(--focus-ring);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* ═══════════ Détail (B12) : panneau de 380 px à droite, plein écran au téléphone */
|
||||
.vf { position: relative; }
|
||||
|
||||
.vf-detail {
|
||||
grid-row: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
border-left: 1px solid var(--border);
|
||||
}
|
||||
.vf-detail:focus { outline: none; }
|
||||
.vf-detail:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: -2px; }
|
||||
|
||||
.vf-detail__tete {
|
||||
order: 0;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.vf-detail__espace { flex: 1 1 0; }
|
||||
.vf-detail__fermer,
|
||||
.vf-detail__partager {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 40px;
|
||||
min-height: 40px;
|
||||
padding: 0 var(--space-xs);
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
background: none;
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: var(--fw-semibold);
|
||||
cursor: pointer;
|
||||
}
|
||||
.vf-detail__fermer { order: 2; }
|
||||
.vf-detail__fermer-tel { display: none; }
|
||||
.vf-detail__fermer-ordi { font-size: 1.05rem; }
|
||||
.vf-detail__partager { order: 1; }
|
||||
|
||||
.vf-detail__nav {
|
||||
order: 1;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-xs);
|
||||
padding: var(--space-2xs) var(--space-sm);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.vf-detail__pas {
|
||||
min-height: 40px;
|
||||
padding: 0 var(--space-xs);
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
background: none;
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: var(--fw-medium);
|
||||
cursor: pointer;
|
||||
}
|
||||
.vf-detail__pas:disabled { color: color-mix(in srgb, var(--text) 45%, transparent); cursor: default; }
|
||||
.vf-detail__rang { font-size: var(--fs-sm); font-weight: var(--fw-semibold); font-variant-numeric: tabular-nums; }
|
||||
|
||||
.vf-detail__corps {
|
||||
order: 2;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: var(--space-sm) var(--space-md) var(--space-lg);
|
||||
}
|
||||
.vf-detail__hors {
|
||||
margin: 0 0 var(--space-sm);
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-alt);
|
||||
font-size: var(--fs-sm);
|
||||
}
|
||||
.vf-detail__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-2xs) var(--space-sm);
|
||||
margin: 0 0 var(--space-sm);
|
||||
font-size: var(--fs-sm);
|
||||
}
|
||||
.vf-detail__actions .vf-lien { min-height: 32px; display: inline-flex; align-items: center; }
|
||||
.vf-detail__statut { flex-basis: 100%; font-size: var(--fs-xs, 0.75rem); color: color-mix(in srgb, var(--text) 78%, transparent); overflow-wrap: anywhere; }
|
||||
.vf-detail__statut:empty { display: none; }
|
||||
.vf-detail__aide {
|
||||
margin: var(--space-md) 0 0;
|
||||
font-size: var(--fs-xs, 0.75rem);
|
||||
color: color-mix(in srgb, var(--text) 78%, transparent);
|
||||
}
|
||||
|
||||
.vf-detail__fermer:focus-visible,
|
||||
.vf-detail__partager:focus-visible,
|
||||
.vf-detail__pas:focus-visible,
|
||||
.vf-detail__actions .vf-lien:focus-visible {
|
||||
outline: 2px solid var(--focus-ring);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Tablette : une colonne de liste ou de carte, le détail à côté */
|
||||
@media (min-width: 768px) {
|
||||
.vf--detail .vf-corps { grid-template-columns: minmax(0, 1fr) 340px; }
|
||||
.vf-detail { grid-column: 2; }
|
||||
}
|
||||
|
||||
/* Ordinateur : troisième colonne de 380 px, quel que soit l'état ; la grille passe à 2 colonnes */
|
||||
@media (min-width: 1024px) {
|
||||
.vf-detail { grid-column: 3; }
|
||||
.vf--detail .vf-corps { grid-template-columns: var(--vf-liste) minmax(0, 1fr) 380px; }
|
||||
.vf--detail.vf--fiches .vf-corps { grid-template-columns: minmax(0, 1fr) 0 380px; }
|
||||
.vf--detail.vf--carte .vf-corps { grid-template-columns: 0 minmax(0, 1fr) 380px; }
|
||||
}
|
||||
@media (min-width: 1280px) {
|
||||
.vf--detail.vf--fiches .vf-grille { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
}
|
||||
|
||||
/* Téléphone : plein écran par-dessus la vue (barre comprise), fiche à fiche en pied, à portée de pouce */
|
||||
@media (max-width: 767.98px) {
|
||||
/* le détail se place par rapport à .vf (barre comprise), pas au corps */
|
||||
.vf--detail .vf-corps { position: static; }
|
||||
.vf-detail {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 1100;
|
||||
border-left: none;
|
||||
}
|
||||
.vf-detail__fermer { order: 0; padding: 0 var(--space-xs) 0 0; }
|
||||
.vf-detail__fermer-tel { display: inline; }
|
||||
.vf-detail__fermer-ordi { display: none; }
|
||||
.vf-detail__partager { order: 2; }
|
||||
.vf-detail__nav {
|
||||
order: 3;
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
border-top: 1px solid var(--border);
|
||||
border-bottom: none;
|
||||
}
|
||||
.vf-detail__aide { display: none; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -17,6 +17,7 @@ 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'
|
||||
|
||||
@@ -50,11 +51,29 @@ export function useVueFiches(
|
||||
}))
|
||||
|
||||
// ── É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 nouvel = { ...etat.value, ...partiel }
|
||||
const base = queryBase()
|
||||
const nouvel = { ...etatDepuis(base), ...partiel }
|
||||
qEcrit = nouvel.q
|
||||
if (minuteurQ && partiel.q === undefined) { clearTimeout(minuteurQ); minuteurQ = null }
|
||||
router.replace({ query: ecrireQuery(nouvel, cfg.value, route.query) })
|
||||
naviguer(ecrireQuery(nouvel, cfg.value, base))
|
||||
}
|
||||
|
||||
function setQ(v: string) {
|
||||
@@ -155,11 +174,78 @@ export function useVueFiches(
|
||||
/** 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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+23
-16
@@ -11,7 +11,6 @@
|
||||
libelle-recherche="Rechercher une structure…"
|
||||
:pending="pending"
|
||||
:selected-id="selectedId"
|
||||
@select="(f) => onSelectStructure(String(f.id))"
|
||||
>
|
||||
<template #carte>
|
||||
<!-- Métropole -->
|
||||
@@ -21,6 +20,7 @@
|
||||
:structures="metropoleStructures"
|
||||
:selectedId="selectedId"
|
||||
:survolId="vf.survolId == null ? null : String(vf.survolId)"
|
||||
:centrage="centrageCarte"
|
||||
@select-structure="onSelectStructure"
|
||||
@survol="(id) => (vf.survolId = id)"
|
||||
/>
|
||||
@@ -63,15 +63,18 @@
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</VueFiches>
|
||||
|
||||
<!-- ═══════════════════════════════════════ MODAL FICHE V2 -->
|
||||
<FicheModalV2
|
||||
v-model="ficheModalOpen"
|
||||
:structureId="ficheModalId"
|
||||
<!-- 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"
|
||||
@update:structureId="ficheModalId = $event"
|
||||
: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
|
||||
@@ -221,18 +224,21 @@ const vf = useVueFiches(fichesMini, config)
|
||||
// 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 : FicheModalV2 à toutes les largeurs (comportement d'avant B11) ──
|
||||
const selectedId = ref<string | null>(null)
|
||||
const ficheModalOpen = ref(false)
|
||||
const ficheModalId = ref<string | null>(null)
|
||||
// ── 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) {
|
||||
const ordinateur = typeof window !== 'undefined' && window.innerWidth >= 1024
|
||||
selectedId.value = ordinateur && selectedId.value === id ? null : id
|
||||
ficheModalId.value = id
|
||||
ficheModalOpen.value = true
|
||||
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 }
|
||||
})
|
||||
|
||||
// ── Boutons flottants (téléphone) : au-dessus du pied « Voir les N fiches » en état Carte ──
|
||||
const chatbotOpen = ref(false)
|
||||
const missionOpen = ref(false)
|
||||
@@ -244,7 +250,8 @@ onMounted(() => {
|
||||
} catch {}
|
||||
})
|
||||
|
||||
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>
|
||||
|
||||
+44
-25
@@ -17,7 +17,7 @@
|
||||
libelle-recherche="Rechercher une structure…"
|
||||
:pending="pending"
|
||||
:selected-id="selectedId"
|
||||
@select="(f) => onSelectOrg(Number(f.id))"
|
||||
:lien-page="(f) => `/fiche/${f.id}`"
|
||||
>
|
||||
<template #carte>
|
||||
<!-- Métropole -->
|
||||
@@ -27,6 +27,7 @@
|
||||
:orgs="metropoleOrgs"
|
||||
:selectedId="selectedId"
|
||||
:survolId="survolNum"
|
||||
:centrage="centrageMetropole"
|
||||
@select-org="onSelectOrg"
|
||||
@survol="(id) => (vf.survolId = id)"
|
||||
/>
|
||||
@@ -55,13 +56,22 @@
|
||||
<ChatbotPlaceholder @highlightOrgs="onHighlightOrgs" />
|
||||
</div>
|
||||
</template>
|
||||
</VueFiches>
|
||||
|
||||
<!-- ═══════════════════════════════════════ MODAL FICHE (ordinateur) -->
|
||||
<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
|
||||
@@ -171,41 +181,49 @@ const metropoleOrgs = computed<Org[]>(() => orgsFiltres.value.filter((o) => !o.t
|
||||
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 ─────────────────────────────────────────────────────────────
|
||||
const selectedId = ref<number | null>(null)
|
||||
const ficheModalOpen = ref(false)
|
||||
const ficheModalId = ref<number | null>(null)
|
||||
// ── 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)
|
||||
|
||||
// Ordinateur : FicheModal ; téléphone et tablette : page /fiche/:id (comportement d'avant B11)
|
||||
function onSelectOrg(id: number) {
|
||||
if (typeof window !== 'undefined' && window.innerWidth >= 1024) {
|
||||
selectedId.value = selectedId.value === id ? null : id
|
||||
ficheModalId.value = id
|
||||
ficheModalOpen.value = true
|
||||
return
|
||||
vf.ouvrirFiche(id)
|
||||
}
|
||||
selectedId.value = 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 {
|
||||
// Le bouton retour de /fiche/:id revient à la même vue, filtres compris
|
||||
sessionStorage.setItem('nav_back_filters', new URLSearchParams(route.query as Record<string, string>).toString())
|
||||
} catch {}
|
||||
router.push(`/fiche/${id}`)
|
||||
}, { 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
|
||||
prevSelectedId.value = selectedId.value
|
||||
selectedId.value = firstId
|
||||
surligneId.value = firstId
|
||||
if (highlightTimer) clearTimeout(highlightTimer)
|
||||
highlightTimer = setTimeout(() => {
|
||||
selectedId.value = prevSelectedId.value
|
||||
prevSelectedId.value = null
|
||||
surligneId.value = null
|
||||
highlightTimer = null
|
||||
}, 5000)
|
||||
}
|
||||
@@ -231,7 +249,8 @@ onMounted(() => {
|
||||
} catch {}
|
||||
})
|
||||
|
||||
useHead({ title: 'AEP — Cartographie de l\'écologie politique architecturale' })
|
||||
// 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>
|
||||
|
||||
@@ -128,5 +128,63 @@ ok(eq(gt.map((x) => x.libelle), ['Martinique', 'Métropole']), 'groupe territoir
|
||||
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)
|
||||
|
||||
+71
-1
@@ -121,7 +121,8 @@ export function lireQuery(query: Query, cfg: ConfigVueFiches): EtatFiches {
|
||||
|
||||
/**
|
||||
* Query à écrire pour un état. Les paramètres que la vue fiches ne gère pas
|
||||
* (`territoire`, `random`, `fiche` de B12…) sont recopiés depuis `base`.
|
||||
* (`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)])
|
||||
@@ -295,3 +296,72 @@ export function regrouper(fiches: MiniFiche[], def: GroupeDef | null, cfg: Confi
|
||||
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