Compare commits

..
Author SHA1 Message Date
Jules NenyandClaude Opus 5.5 0c8f9fc1bd fix(outils): un seul repère main, tabpanel sur le panneau ; doublons retirés de l'arbre fmhy (B6-M5)
Le repère <main> passe à la racine de la page et le panneau d'onglet devient
un <div role="tabpanel"> (Lighthouse signalait role=tabpanel sur <main>).
FreeCAD, Blender, Inkscape, GIMP, LibreOffice Draw et Joplin sortent de
fmhy-curated.json : ils sont désormais dans la sélection open source.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0136n5xMLHuaBDLcPJYCDjRW
2026-10-08 17:10:10 +02:00
Jules NenyandClaude Sonnet f4ea1639f2 chore(outils): script de contrôle des liens, outil de revue (B6-M4)
Co-Authored-By: Claude Sonnet <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0136n5xMLHuaBDLcPJYCDjRW
2026-10-08 17:07:37 +02:00
Jules NenyandClaude Sonnet 43ae065e26 feat(outils): sélection open source, sommaire d'ancres et garde-fous (B6-M4)
28 outils vérifiés en 4 rubriques (open_source.selection), affichés en premier
sous un sommaire d'ancres ; grille 2 colonnes dès 1024 px. Le test contrôle
la sélection (champs, https, liste noire) et les doublons d'URL.

Co-Authored-By: Claude Sonnet <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0136n5xMLHuaBDLcPJYCDjRW
2026-10-08 17:07:37 +02:00
Jules NenyandClaude Sonnet dfbac7dd12 feat(outils): Partir de soi en vue fiches, graphe des sept mots (B6-M2)
Le 3e sous-onglet de /outils remplace le rendu provisoire par VueFiches
(état Mixte : liste des mots, graphe SVG statique, fiche d'un mot avec ses
ressources). L'en-tête se compacte sur cet onglet, la vue prend la hauteur.

Co-Authored-By: Claude Sonnet <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0136n5xMLHuaBDLcPJYCDjRW
2026-10-08 16:56:18 +02:00
Jules NenyandClaude Sonnet 11f24c7925 feat(vue-fiches): tri ordre d'origine, triDefaut, libellés de vue et adaptateur mot (B6-M2)
Extensions optionnelles du composant partagé, sans effet sur / et /agences :
tri 'ordre' (ordre du tableau d'entrée) avec config.triDefaut et libellesTri,
prop libellesVue (Graphe au lieu de Carte, Voir dans le graphe), adaptateur
toMiniMot (kind 'mot'). 22 cas ajoutés à test-vue-fiches (80/80).

Co-Authored-By: Claude Sonnet <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0136n5xMLHuaBDLcPJYCDjRW
2026-10-08 16:56:18 +02:00
Jules NenyandClaude Sonnet 68d3b080d7 feat(outils): trois sous-onglets, contenu sorti du template (B6-M1)
Co-Authored-By: Claude Sonnet <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0136n5xMLHuaBDLcPJYCDjRW
2026-10-08 16:38:29 +02:00
Jules NenyandClaude Sonnet c6123f1f59 test(outils): garde-fou des données outils.json (B6-M1)
Co-Authored-By: Claude Sonnet <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0136n5xMLHuaBDLcPJYCDjRW
2026-10-08 16:38:29 +02:00
Jules NenyandClaude Sonnet 4f0f0d95c9 feat(outils): outils.json schéma v2 et purge des liens piratage (B6-M1)
Co-Authored-By: Claude Sonnet <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0136n5xMLHuaBDLcPJYCDjRW
2026-10-08 16:38:28 +02:00
Jules NenyandClaude Opus 5.5 1054172ec9 docs(design-system): §Vue fiches — détail, fiche à fiche, historique, contrat #detail (B12)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AGNSEFZdoKvbdsGyfSnu5S
2026-10-03 09:20:16 +02:00
Jules NenyandClaude Opus 5.5 a9befb25ab fix(vue-fiches): passe /autofeedback — Échap dans un champ garde le brouillon, titre d'onglet = fiche ouverte, contrastes et mini-carte décorative de FicheDetail
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AGNSEFZdoKvbdsGyfSnu5S
2026-10-03 09:20:16 +02:00
Jules NenyandClaude Opus 5.5 a6dcf48c71 fix(fiche-detail): mini-carte dessinée quand la fiche s'ouvre côté client (conteneur ClientOnly attendu)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AGNSEFZdoKvbdsGyfSnu5S
2026-10-03 09:09:37 +02:00
Jules NenyandClaude Opus 5.5 5e0027b861 feat(fiches): / et /agences ouvrent la fiche dans le panneau ; FicheReseauContenu extrait, FicheModal et FicheModalV2 retirées ; Voir sur la carte (B12-M4)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AGNSEFZdoKvbdsGyfSnu5S
2026-10-03 08:57:16 +02:00
Jules NenyandClaude Opus 5.5 1bb55538a4 feat(vue-fiches): panneau de détail à droite, plein écran au téléphone ; clavier, balayage, copier le lien (B12-M1/M2)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AGNSEFZdoKvbdsGyfSnu5S
2026-10-03 08:57:16 +02:00
Jules NenyandClaude Opus 5.5 fe1043e321 feat(vue-fiches): fiche= dans l'URL, fiche à fiche, historique push/replace/back (B12-M2/M3)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AGNSEFZdoKvbdsGyfSnu5S
2026-10-03 08:57:16 +02:00
Jules NenyandClaude Fable 5.1 55027ba482 fix(chatbot-pensees): défaut serveur = corpus Pensées seules (AEP-PREFACE, ok Jules 03/10)
Le mode croisé Pensées × Projets reste un choix explicite du visiteur (sélecteur du chatbot).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014PHyAMzRnXo8zPQYkUNopU
2026-10-03 00:28:00 +02:00
23 changed files with 3485 additions and 1305 deletions
+14 -7
View File
@@ -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,24 +78,31 @@ 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.
**Troisième page en place : `/outils?tab=partir-de-soi` (B6-M2)** — sept fiches, une par mot de la slide 17 (`components/OutilsPartirDeSoi.vue`, adaptateur `toMiniMot`). Trois extensions optionnelles du composant, sans effet sur `/` et `/agences` : tri `ordre` (ordre du tableau d'entrée) avec `config.triDefaut` (tri sans filtre ; la pertinence reste prioritaire dès qu'un filtre est actif) et `config.libellesTri` ; prop `libelles-vue` (`{ carte: 'Graphe' }` : le bouton, et « Voir dans le graphe » au lieu de « Voir sur la carte » ; l'URL garde `vue=carte`). Le slot `#carte` y est `GrapheMots.vue`, un SVG statique (nœud central, mots en couronne, ressources en feuilles) dans le vocabulaire du motif maillage. La page vit dans un onglet : `tab=` est un paramètre que la vue ne gère pas, donc recopié à chaque écriture.
**`FicheMiniCard`** : racine `<article>`, toute la carte ouvre la fiche par un bouton étiré (`::after`) ; avec `etiquettes-cliquables`, les chips deviennent des boutons (impossible dans l'ancienne racine `<button>`). Props `actif` (liseré safran au survol d'épingle), `etiquettes-actives`. Résumé et « Sans localisation » en `color-mix(--text 78 %)` : `--text-muted` et `--text-subtle` échouaient au contraste.
## Intention d'abord
+57 -26
View File
@@ -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;
-183
View File
@@ -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>
-360
View File
@@ -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>
+291
View File
@@ -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>
+314
View File
@@ -0,0 +1,314 @@
<!--
GrapheMots — B6-M2. Le graphe des sept mots de « Partir de soi » : un nœud
central, les mots en couronne (ordre de la slide, sens horaire depuis le
haut), et autour de chaque mot ses ressources en petits nœuds-feuilles.
Vocabulaire du motif maillage (DESIGN-SYSTEM) : nœuds pleins, arêtes fines,
un nœud central mis en avant. Statique et déterministe : pas de simulation,
rien ne bouge ; la mise en page est une fonction pure de la taille mesurée.
Il vit dans le slot #carte de VueFiches et ne connaît que `vf` :
clic / Entrée sur un mot ouvre sa fiche (`vf.ouvrirFiche`), le mot ouvert
(`vf.ficheId`) et le mot survolé dans la liste (`vf.survolId`) s'allument,
les mots hors du résultat (`vf.idsResultat`) s'estompent sans disparaître.
Les feuilles ne sont pas dans l'ordre de tabulation (on y accède par leur
mot) ; leur libellé apparaît au survol.
-->
<template>
<div ref="racine" class="gm">
<svg
class="gm__svg"
:viewBox="`0 0 ${taille.w} ${taille.h}`"
preserveAspectRatio="xMidYMid meet"
role="group"
aria-labelledby="gm-titre"
>
<title id="gm-titre">Graphe des sept mots de « Partir de soi » et des ressources de chacun</title>
<!-- Arêtes : centre → mot, mot → feuilles -->
<g class="gm__aretes" aria-hidden="true">
<template v-for="n in noeuds" :key="`a-${n.id}`">
<line
:x1="centre.x" :y1="centre.y" :x2="n.x" :y2="n.y"
class="gm__arete" :class="{ 'gm__arete--vif': allume(n.id), 'gm__arete--hors': hors(n.id) }"
/>
<line
v-for="f in n.feuilles" :key="`a-${n.id}-${f.id}`"
:x1="n.x" :y1="n.y" :x2="f.x" :y2="f.y"
class="gm__arete gm__arete--feuille" :class="{ 'gm__arete--vif': allume(n.id), 'gm__arete--hors': hors(n.id) }"
/>
</template>
</g>
<!-- Nœud central -->
<g class="gm__centre" aria-hidden="true">
<circle :cx="centre.x" :cy="centre.y" :r="RAYON_CENTRE" class="gm__centre-rond" />
<text :x="centre.x" :y="centre.y - 7" class="gm__centre-texte" text-anchor="middle">Partir</text>
<text :x="centre.x" :y="centre.y + 8" class="gm__centre-texte" text-anchor="middle">de soi</text>
</g>
<!-- Feuilles (ressources) -->
<g v-for="n in noeuds" :key="`f-${n.id}`" class="gm__feuilles" :class="{ 'gm__feuilles--hors': hors(n.id) }">
<circle
v-for="f in n.feuilles" :key="f.id"
:cx="f.x" :cy="f.y" r="5.5"
class="gm__feuille" :class="{ 'gm__feuille--vive': allume(n.id) || survolFeuille === `${n.id}/${f.id}` }"
@click="vf.ouvrirFiche(n.id)"
@mouseenter="survolerFeuille(n.id, f.id)"
@mouseleave="survolerFeuille(null, null)"
>
<title>{{ f.titre }}</title>
</circle>
</g>
<!-- Mots -->
<g
v-for="n in noeuds"
:key="n.id"
class="gm__mot"
:class="{ 'gm__mot--actif': allume(n.id), 'gm__mot--hors': hors(n.id), 'gm__mot--vide': n.vide }"
role="button"
tabindex="0"
:aria-label="n.aria"
:aria-pressed="actif(n.id)"
@click="vf.ouvrirFiche(n.id)"
@keydown.enter.prevent="vf.ouvrirFiche(n.id)"
@keydown.space.prevent="vf.ouvrirFiche(n.id)"
@mouseenter="vf.survolId = n.id"
@mouseleave="vf.survolId = null"
@focus="vf.survolId = n.id"
@blur="vf.survolId = null"
>
<rect
:x="n.x - n.l / 2" :y="n.y - n.h / 2" :width="n.l" :height="n.h" :rx="n.h / 2"
class="gm__pilule"
/>
<text :x="n.x" :y="n.y" text-anchor="middle" class="gm__libelle" aria-hidden="true">
<tspan v-for="(ligne, i) in n.lignes" :key="i" :x="n.x" :y="n.y + (i - (n.lignes.length - 1) / 2) * INTERLIGNE + 4.5">{{ ligne }}</tspan>
</text>
</g>
<!-- Libellé de la feuille survolée, au-dessus de tout -->
<g v-if="bulle" class="gm__bulle" aria-hidden="true">
<rect :x="bulle.x - bulle.l / 2" :y="bulle.y - 11" :width="bulle.l" height="22" rx="6" class="gm__bulle-fond" />
<text :x="bulle.x" :y="bulle.y + 4" text-anchor="middle" class="gm__bulle-texte">{{ bulle.texte }}</text>
</g>
</svg>
</div>
</template>
<script setup lang="ts">
import type { VueFichesEtat } from '~/composables/useVueFiches'
const props = defineProps<{
vf: VueFichesEtat
mots: { id: string; mot: string; ressources: { id: string; titre: string }[]; vide?: boolean }[]
}>()
const RAYON_CENTRE = 36
const INTERLIGNE = 15
const CARACTERES_LIGNE = 16
const PORTEE_FEUILLES = 46
const MARGE = 10
// ── Taille : mesurée au montage (le rendu serveur part d'une taille plausible) ──
const racine = ref<HTMLElement | null>(null)
const taille = reactive({ w: 720, h: 600 })
onMounted(() => {
const el = racine.value
if (!el || typeof ResizeObserver === 'undefined') return
const maj = (w: number, h: number) => {
if (w > 40 && h > 40) { taille.w = Math.round(w); taille.h = Math.round(h) }
}
maj(el.clientWidth, el.clientHeight)
const ro = new ResizeObserver((entrees) => {
const r = entrees[0]?.contentRect
if (r) maj(r.width, r.height)
})
ro.observe(el)
onBeforeUnmount(() => ro.disconnect())
})
// ── Mise en page : fonction pure de (taille, mots) ──
/** Une ligne si ça tient, sinon deux lignes équilibrées (« naviguer / avec soi », pas « naviguer avec / soi »). */
function couper(texte: string): string[] {
if (texte.length <= CARACTERES_LIGNE) return [texte]
const mots = texte.split(' ')
let meilleur: string[] = [texte]
let pire = Infinity
for (let i = 1; i < mots.length; i++) {
const l = [mots.slice(0, i).join(' '), mots.slice(i).join(' ')]
const m = Math.max(l[0].length, l[1].length)
if (m < pire) { pire = m; meilleur = l }
}
return meilleur
}
function tronquer(t: string, max = 44): string {
return t.length > max ? `${t.slice(0, max - 1).trimEnd()}…` : t
}
const centre = computed(() => ({ x: taille.w / 2, y: taille.h / 2 }))
const noeuds = computed(() => {
const n = props.mots.length
const { w, h } = taille
const base = props.mots.map((m) => {
const lignes = couper(m.mot)
return { m, lignes, l: Math.max(...lignes.map((x) => x.length)) * 6.9 + 24, h: lignes.length * INTERLIGNE + 11 }
})
const lMax = Math.max(...base.map((b) => b.l))
const hMax = Math.max(...base.map((b) => b.h))
const rx = Math.max(48, w / 2 - lMax / 2 - PORTEE_FEUILLES - MARGE)
const ry = Math.max(70, h / 2 - hMax / 2 - PORTEE_FEUILLES - MARGE)
const cx = centre.value.x
const cy = centre.value.y
// Colonne étroite : la couronne ne tient plus (les mots du bas se chevaucheraient).
// Les mots s'étagent alors en zigzag autour du centre, une rangée chacun, dans le
// même sens de lecture (haut, puis la droite en descendant, puis la gauche en remontant),
// et leurs ressources se rangent SOUS le mot.
const etroit = rx < lMax * 1.3
let place: { x: number; y: number }[]
if (etroit) {
const droite = Math.ceil((n - 1) / 2)
const rangsDroite = Array.from({ length: droite }, (_, i) => 1 + 2 * i)
const rangsGauche = Array.from({ length: n }, (_, i) => i).filter((r) => r > 0 && !rangsDroite.includes(r)).reverse()
const rang = (i: number) => (i === 0 ? 0 : i <= droite ? rangsDroite[i - 1] : rangsGauche[i - droite - 1])
const y0 = hMax / 2 + MARGE
const y1 = h - hMax / 2 - MARGE - 24 // de la place sous le dernier mot pour ses feuilles
const pas = n > 1 ? (y1 - y0) / (n - 1) : 0
const dx = Math.max(0, w / 2 - lMax / 2 - 6)
place = base.map((_, i) => ({ x: i === 0 ? cx : i <= droite ? cx + dx : cx - dx, y: y0 + rang(i) * pas }))
} else {
place = base.map((_, i) => {
const a = -Math.PI / 2 + (i * 2 * Math.PI) / n // depuis le haut, sens horaire
return { x: cx + rx * Math.cos(a), y: cy + ry * Math.sin(a) }
})
}
return base.map((b, i) => {
const { x, y } = place[i]
const k = b.m.ressources.length
let feuilles: { id: string; titre: string; x: number; y: number }[]
if (etroit) {
const ecart = k > 1 ? Math.min(17, (b.l - 20) / (k - 1)) : 0
feuilles = b.m.ressources.map((r, j) => ({
id: r.id,
titre: r.titre,
x: x + (j - (k - 1) / 2) * ecart,
y: y + b.h / 2 + 13 + (j % 2 ? 9 : 0),
}))
} else {
// direction « vers l'extérieur » réelle (l'ellipse n'est pas un cercle)
const dir = Math.atan2(y - cy, x - cx)
const pas = Math.min(0.55, 2.3 / Math.max(k, 1))
const dist = Math.abs(Math.cos(dir)) * (b.l / 2) + Math.abs(Math.sin(dir)) * (b.h / 2) + 20
feuilles = b.m.ressources.map((r, j) => {
const t = dir + (j - (k - 1) / 2) * pas
const d = dist + (j % 2 ? 12 : 0)
return {
id: r.id,
titre: r.titre,
x: Math.min(w - 8, Math.max(8, x + d * Math.cos(t))),
y: Math.min(h - 8, Math.max(8, y + d * Math.sin(t))),
}
})
}
return {
id: b.m.id,
x, y, l: b.l, h: b.h, lignes: b.lignes,
vide: !!b.m.vide || k === 0,
aria: `${b.m.mot}, ${k === 0 ? 'aucune ressource pour l’instant' : k === 1 ? '1 ressource' : `${k} ressources`}`,
feuilles,
}
})
})
// ── États (tout vient de vf) ──
const actif = (id: string) => String(props.vf.ficheId ?? '') === id
const survol = (id: string) => props.vf.survolId != null && String(props.vf.survolId) === id
const allume = (id: string) => actif(id) || survol(id)
const hors = (id: string) => !props.vf.idsResultat.has(id)
const survolFeuille = ref<string | null>(null)
function survolerFeuille(mot: string | null, feuille: string | null) {
survolFeuille.value = mot && feuille ? `${mot}/${feuille}` : null
props.vf.survolId = mot
}
const bulle = computed(() => {
if (!survolFeuille.value) return null
const [mot, feuille] = survolFeuille.value.split('/')
const f = noeuds.value.find((n) => n.id === mot)?.feuilles.find((x) => x.id === feuille)
if (!f) return null
const texte = tronquer(f.titre)
const l = texte.length * 6.3 + 16
const x = Math.min(taille.w - l / 2 - 4, Math.max(l / 2 + 4, f.x))
const y = f.y < 30 ? f.y + 24 : f.y - 18
return { x, y, l, texte }
})
</script>
<style scoped>
.gm {
position: relative;
flex: 1;
min-height: 0;
min-width: 0;
overflow: hidden;
background: var(--bg-alt);
}
.gm__svg {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
display: block;
font-family: inherit;
}
/* Arêtes fines */
.gm__arete { stroke: var(--nav-primary-solid); stroke-width: 1; stroke-opacity: 0.35; }
.gm__arete--feuille { stroke-opacity: 0.28; }
.gm__arete--vif { stroke-width: 2; stroke-opacity: 0.8; }
.gm__arete--hors { stroke-opacity: 0.12; }
/* Nœud central, mis en avant */
.gm__centre-rond { fill: var(--nav-primary-solid); stroke: var(--accent); stroke-width: 3; }
.gm__centre-texte { fill: var(--nav-text-on-primary); font-size: 12px; font-weight: 700; }
/* Feuilles : petits nœuds pleins */
.gm__feuille {
fill: var(--nav-primary-solid);
stroke: var(--bg-alt);
stroke-width: 1.5;
cursor: pointer;
transition: fill 120ms ease-out;
}
.gm__feuille--vive { fill: var(--accent); stroke: var(--nav-primary-solid); }
.gm__feuilles--hors { opacity: 0.3; }
/* Mots : pastilles qui portent leur libellé */
.gm__mot { cursor: pointer; outline: none; transition: opacity 120ms ease-out; }
.gm__pilule {
fill: var(--surface);
stroke: var(--nav-primary-solid);
stroke-width: 2;
transition: fill 120ms ease-out, stroke 120ms ease-out;
}
.gm__libelle { fill: var(--text); font-size: 12.5px; font-weight: 600; pointer-events: none; }
.gm__mot--vide .gm__pilule { stroke-dasharray: 5 4; }
.gm__mot--actif .gm__pilule { fill: var(--accent); stroke: var(--nav-primary-solid); }
.gm__mot--actif .gm__libelle { fill: var(--on-accent); }
.gm__mot--hors { opacity: 0.4; }
.gm__mot:focus-visible .gm__pilule { stroke: var(--focus-ring); stroke-width: 4; }
/* Libellé de feuille */
.gm__bulle { pointer-events: none; }
.gm__bulle-fond { fill: var(--surface); stroke: var(--nav-primary-solid); stroke-width: 1; }
.gm__bulle-texte { fill: var(--text); font-size: 11.5px; font-weight: 500; }
@media (prefers-reduced-motion: reduce) {
.gm__feuille, .gm__mot, .gm__pilule { transition: none; }
}
</style>
+18
View File
@@ -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>
+18
View File
@@ -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>
+225
View File
@@ -0,0 +1,225 @@
<!--
OutilsPartirDeSoi — B6-M2. Le sous-onglet « Partir de soi » de /outils : les
sept mots de la slide 17 de la conférence, un mot = une fiche, rendus par
la vue fiches partagée (état Mixte : liste des mots | graphe | fiche ouverte).
Troisième page branchée sur VueFiches (DESIGN-SYSTEM §Brancher une troisième
page). Les données viennent de `partir_de_soi` dans public/data/outils.json.
Monté seulement quand `?tab=partir-de-soi` : l'état de la vue (q, filtres,
fiche…) vit dans l'URL et disparaît avec l'onglet. `tab=` est recopié par la
vue à chacune de ses écritures (paramètre qu'elle ne gère pas).
-->
<template>
<div class="ops">
<VueFiches
:vf="vf"
intention="Sept mots de la conférence, et pour chacun des ressources libres qui m'ont aidé ou qu'on m'a confiées. Ouvre un mot."
libelle-recherche="Rechercher une ressource…"
:libelles-vue="{ carte: 'Graphe' }"
:lien-page="() => null"
>
<template #carte>
<GrapheMots :vf="vf" :mots="motsGraphe" />
</template>
<template #detail="{ fiche, filtrer }">
<article v-if="parId.get(String(fiche.id))" class="mot">
<h2 class="mot__titre">{{ parId.get(String(fiche.id))!.mot }}</h2>
<p class="mot__resume">{{ parId.get(String(fiche.id))!.resume }}</p>
<template v-if="parId.get(String(fiche.id))!.vide">
<p class="mot__vide">{{ parId.get(String(fiche.id))!.vide }}</p>
<NuxtLink to="/proposer?categorie=outils" class="mot__proposer">Proposer une ressource</NuxtLink>
</template>
<ul v-else class="ress" :aria-label="`Ressources pour ${parId.get(String(fiche.id))!.mot}`">
<li v-for="r in ressourcesDe(String(fiche.id))" :key="r.id" class="ress__carte">
<p class="ress__titre">
<a v-if="r.url" :href="r.url" target="_blank" rel="noopener noreferrer">{{ r.titre }}<span class="ress__ext" aria-hidden="true"> ↗</span><span class="vf-sr-mot"> (nouvel onglet)</span></a>
<template v-else>{{ r.titre }}</template>
</p>
<ChipGroup :aria-label="`Format et accès de ${r.titre}`" class="ress__chips">
<Chip
:label="r.format"
variant="fonction"
:active="(vf.etat.filtres.format ?? []).includes(slugEtiquette(r.format))"
:aria-label="`Filtrer par format : ${r.format}`"
@toggle="filtrer('format', slugEtiquette(r.format))"
/>
<Chip
:label="r.acces"
variant="echelle"
:active="(vf.etat.filtres.acces ?? []).includes(slugEtiquette(r.acces))"
:aria-label="`Filtrer par accès : ${r.acces}`"
@toggle="filtrer('acces', slugEtiquette(r.acces))"
/>
</ChipGroup>
<p v-if="r.pourquoi" class="ress__pourquoi">{{ r.pourquoi }}</p>
<p v-if="r.urls?.length" class="ress__liens">
<template v-for="(u, i) in r.urls" :key="u">
<span v-if="i > 0" aria-hidden="true"> · </span>
<a :href="u" target="_blank" rel="noopener noreferrer">{{ u.replace(/^https:\/\//, '') }}<span class="vf-sr-mot"> (nouvel onglet)</span></a>
</template>
</p>
<p v-if="r.note" class="ress__note">{{ r.note }}</p>
<ul v-if="r.episodes?.length" class="eps" :aria-label="`Épisodes de ${r.titre}`">
<li v-for="ep in r.episodes" :key="ep.ep" class="eps__item" :class="{ 'eps__item--muet': !ep.url }">
<span class="eps__ep">EP/{{ ep.ep }}</span>
<span class="eps__corps">
<a v-if="ep.url" :href="ep.url" target="_blank" rel="noopener noreferrer" class="eps__titre">{{ ep.titre }}<span class="vf-sr-mot"> (nouvel onglet)</span></a>
<span v-else class="eps__titre">{{ ep.titre }}</span>
<span class="eps__personnes"> — {{ ep.personnes }}</span>
<span v-if="ep.note" class="eps__note"> ({{ ep.note }})</span>
</span>
</li>
</ul>
</li>
</ul>
</article>
</template>
</VueFiches>
</div>
</template>
<script setup lang="ts">
import type { ConfigVueFiches } from '~/utils/vueFiches'
import { toMiniMot, valeursRessources, slugEtiquette, type FicheMot, type RessourceMot } from '~/composables/useFicheAdapter'
interface RessourceComplete extends RessourceMot {
url?: string | null
urls?: string[]
note?: string
episodes?: { ep: string; titre: string; personnes: string; url?: string | null; note?: string }[]
}
interface DonneesPartirDeSoi {
fiches: (FicheMot & { vide?: string | null })[]
ressources: RessourceComplete[]
}
const props = defineProps<{ data: DonneesPartirDeSoi }>()
const ressourcesDe = (motId: string) => props.data.ressources.filter((r) => r.fiches.includes(motId))
const parId = computed(() => new Map(props.data.fiches.map((f) => [f.id, f])))
// Étape 1 : adaptateur (un mot → une MiniFiche). L'ordre du tableau est celui de la slide.
const fichesMini = computed(() => props.data.fiches.map((f) => toMiniMot(f, ressourcesDe(f.id))))
// Étape 2 : configuration. Sept fiches, pas de groupe ; tri « ordre de la slide » par défaut.
const config = computed<ConfigVueFiches>(() => ({
dimensions: [
{ cle: 'format', libelle: 'Format', variante: 'fonction', valeurs: valeursRessources(props.data.ressources, 'format') },
{ cle: 'acces', libelle: 'Accès', variante: 'echelle', valeurs: valeursRessources(props.data.ressources, 'acces') },
],
tris: ['ordre', 'pertinence', 'nom'],
triDefaut: 'ordre',
libellesTri: { ordre: 'Ordre de la slide' },
groupes: [],
etats: ['carte', 'mixte', 'fiches'],
}))
const vf = useVueFiches(fichesMini, config)
// Étape 3 : le graphe (slot #carte) lit les mêmes données et l'état de la vue.
const motsGraphe = computed(() =>
props.data.fiches.map((f) => ({
id: f.id,
mot: f.mot,
vide: !!f.vide,
ressources: ressourcesDe(f.id).map((r) => ({ id: r.id, titre: r.titre })),
})),
)
// Une fiche ouverte donne son nom à l'onglet du navigateur.
useSeoMeta({
title: () => (vf.ficheOuverte ? `${vf.ficheOuverte.nom} — Partir de soi — AEP` : 'Outils — AEP — Partir de soi'),
})
</script>
<style scoped>
.ops { height: 100%; min-height: 0; min-width: 0; }
/* Lecteurs d'écran seulement */
.vf-sr-mot {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
}
/* Fiche d'un mot */
.mot__titre { margin: 0 0 var(--space-xs); font-size: 1.25rem; font-weight: var(--fw-bold); line-height: 1.25; }
.mot__resume {
margin: 0 0 var(--space-md);
font-size: var(--fs-sm);
line-height: 1.55;
color: color-mix(in srgb, var(--text) 78%, transparent);
}
.mot__vide {
margin: 0 0 var(--space-sm);
padding: var(--space-sm);
border: 1.5px dashed var(--border-strong);
border-radius: var(--radius-md);
font-size: var(--fs-sm);
line-height: 1.55;
}
.mot__proposer {
display: inline-flex;
align-items: center;
min-height: 40px;
padding: 0 var(--space-md);
border-radius: var(--radius-md);
background: var(--accent);
color: var(--on-accent);
font-size: var(--fs-sm);
font-weight: var(--fw-semibold);
text-decoration: none;
}
.mot__proposer:hover { opacity: 0.9; }
.mot__proposer:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 2px; }
/* Ressources */
.ress { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: var(--space-sm); }
.ress__carte {
display: flex;
flex-direction: column;
gap: var(--space-xs);
min-width: 0;
padding: var(--space-sm);
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--bg);
}
.ress__carte p { margin: 0; overflow-wrap: anywhere; }
.ress__titre { font-size: var(--fs-sm); font-weight: var(--fw-semibold); line-height: 1.35; }
.ress__titre a,
.ress__liens a { color: var(--nav-primary-solid); text-underline-offset: 2px; }
.ress__titre a:focus-visible,
.ress__liens a:focus-visible,
.eps__titre:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 2px; border-radius: var(--radius-sm); }
.ress__pourquoi { font-size: var(--fs-sm); line-height: 1.5; }
.ress__liens { font-size: var(--fs-xs, 0.75rem); }
.ress__note { font-size: var(--fs-xs, 0.75rem); font-style: italic; color: color-mix(in srgb, var(--text) 78%, transparent); }
/* Épisodes OFQA */
.eps { list-style: none; margin: var(--space-2xs) 0 0; padding: 0; display: flex; flex-direction: column; gap: var(--space-2xs); }
.eps__item { display: flex; gap: var(--space-xs); align-items: baseline; font-size: var(--fs-sm); line-height: 1.4; }
.eps__ep {
flex: 0 0 3.6rem;
font-family: var(--font-mono, 'JetBrains Mono', 'Courier New', monospace);
font-size: var(--fs-xs, 0.75rem);
font-weight: var(--fw-semibold);
color: color-mix(in srgb, var(--text) 78%, transparent);
}
.eps__corps { min-width: 0; overflow-wrap: anywhere; }
.eps__titre { font-weight: var(--fw-medium); color: var(--text); }
a.eps__titre { color: var(--nav-primary-solid); }
.eps__personnes,
.eps__note { color: color-mix(in srgb, var(--text) 78%, transparent); font-size: var(--fs-xs, 0.75rem); }
.eps__note { font-style: italic; }
.eps__item--muet .eps__titre { color: color-mix(in srgb, var(--text) 78%, transparent); }
</style>
+16
View File
@@ -14,6 +14,7 @@
<span v-if="tag" :class="['simu-feature__badge', `simu-feature__badge--${tag}`]">{{ tagLabel }}</span>
</div>
<p class="simu-feature__desc">{{ description }}</p>
<p v-if="mention" class="simu-feature__mention">{{ mention }}</p>
</div>
</div>
<span v-if="cta && url" class="simu-feature__cta">{{ cta }}</span>
@@ -29,6 +30,8 @@ const props = defineProps<{
description?: string
cta?: string
tag?: string
/** Ligne de réserve datée sous la description (ex. « Ordre de grandeur — version du … »). */
mention?: string | null
}>()
const tagLabels: Record<string, string> = {
@@ -127,6 +130,13 @@ const tagLabel = computed(() => props.tag ? (tagLabels[props.tag] ?? props.tag)
line-height: 1.55;
}
.simu-feature__mention {
font-size: 0.78rem;
font-style: italic;
color: color-mix(in srgb, var(--text) 78%, transparent);
margin: 0.4rem 0 0;
}
.simu-feature__cta {
display: inline-flex;
align-items: center;
@@ -145,6 +155,12 @@ const tagLabel = computed(() => props.tag ? (tagLabels[props.tag] ?? props.tag)
opacity: 0.88;
}
@media (max-width: 640px) {
.simu-feature { padding: 1.1rem 1rem; }
.simu-feature__inner { flex-direction: column; align-items: stretch; gap: 1rem; }
.simu-feature__cta { justify-content: center; }
}
:global(.dark) .simu-feature__badge {
background: #064e3b;
color: #a7f3d0;
+335 -9
View File
@@ -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"
@@ -23,7 +23,7 @@
:class="`vf-seg__btn--${e}`"
:aria-pressed="vf.vueAffichee === e"
@click="vf.setVue(e)"
>{{ LIBELLES_VUE[e] }}<span v-if="e === 'fiches'" class="vf-seg__n"> · {{ vf.resultat.length }}</span></button>
>{{ libellesVue?.[e] ?? LIBELLES_VUE[e] }}<span v-if="e === 'fiches'" class="vf-seg__n"> · {{ vf.resultat.length }}</span></button>
</div>
<label class="vf-recherche">
@@ -79,7 +79,7 @@
<label class="vf-select vf-select--tri">
<span>Tri</span>
<select :value="vf.tri" @change="vf.setTri(($event.target as HTMLSelectElement).value as TriFiches)">
<option v-for="t in vf.cfg.tris" :key="t" :value="t" :selected="t === vf.tri">{{ LIBELLES_TRI[t] }}</option>
<option v-for="t in vf.cfg.tris" :key="t" :value="t" :selected="t === vf.tri">{{ vf.cfg.libellesTri?.[t] ?? LIBELLES_TRI[t] }}</option>
</select>
</label>
@@ -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)">{{ libelleVoirCarte }}</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,10 @@ 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
/** Libellés des trois états quand « Carte » n'est pas le mot juste (ex. { carte: 'Graphe' }). Le paramètre d'URL reste `vue=carte`. */
libellesVue?: Partial<Record<EtatVue, string>>
}>()
const emit = defineEmits<{
@@ -249,6 +321,8 @@ const emit = defineEmits<{
}>()
const LIBELLES_VUE: Record<EtatVue, string> = { carte: 'Carte', mixte: 'Mixte', fiches: 'Fiches' }
/** « Voir sur la carte » suit le mot choisi pour l'état Carte : « Voir dans le graphe ». */
const libelleVoirCarte = computed(() => (props.libellesVue?.carte ? `Voir dans le ${props.libellesVue.carte.toLowerCase()}` : 'Voir sur la carte'))
const etatsBoutons = computed(() => (['carte', 'mixte', 'fiches'] as EtatVue[]).filter((e) => props.vf.cfg.etats.includes(e)))
// ── Pastilles actives (état Carte, et téléphone en Fiches) ──
@@ -336,6 +410,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 +985,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>
+61 -2
View File
@@ -27,7 +27,7 @@ export interface MiniFiche {
chipsDimension?: string
echelle?: string
geoloc: boolean
kind: 'org' | 'structure'
kind: 'org' | 'structure' | 'mot'
/** Étiquettes complètes par dimension (valeurs telles qu'écrites dans l'URL). */
etiquettes?: Record<string, string[]>
coords?: [number, number]
@@ -134,6 +134,65 @@ export function toMini(source: Org | StructureV2): MiniFiche {
}
}
// ── Partir de soi (B6-M2) : un mot de la slide 17 = une fiche ─────────────
/** Une fiche de `public/data/outils.json` → `partir_de_soi.fiches[]`. */
export interface FicheMot {
id: string
mot: string
resume: string
vide?: string | null
}
/** Une ressource de `partir_de_soi.ressources[]`, reliée à ses mots par `fiches`. */
export interface RessourceMot {
id: string
titre: string
format: string
acces: string
pourquoi?: string
fiches: string[]
}
/** « don libre » → `don-libre` : la forme d'une valeur de filtre dans l'URL (`acces=don-libre`). */
export function slugEtiquette(s: string): string {
return s.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
}
/** Valeurs distinctes d'un champ des ressources, dans l'ordre de première apparition (ordre stable des chips). */
export function valeursRessources(ressources: RessourceMot[], champ: 'format' | 'acces'): { valeur: string; libelle: string }[] {
const vus = new Map<string, string>()
for (const r of ressources) {
const l = r[champ]
if (l && !vus.has(slugEtiquette(l))) vus.set(slugEtiquette(l), l)
}
return [...vus].map(([valeur, libelle]) => ({ valeur, libelle }))
}
/**
* Un mot de la conférence → `MiniFiche`. `ressources` = celles qui portent ce mot.
* Pas de carte géographique : `geoloc: true` (aucune fiche « sans adresse »).
* La recherche couvre aussi les titres et les « pourquoi » des ressources.
*/
export function toMiniMot(fiche: FicheMot, ressources: RessourceMot[]): MiniFiche {
const formats = [...new Set(ressources.map((r) => r.format).filter(Boolean))]
const acces = [...new Set(ressources.map((r) => r.acces).filter(Boolean))]
return {
id: fiche.id,
nom: fiche.mot,
resume: buildResume([fiche.resume, fiche.vide]),
chips: formats.slice(0, 3),
chipsDimension: 'format',
geoloc: true,
kind: 'mot',
etiquettes: {
format: formats.map(slugEtiquette),
acces: acces.map(slugEtiquette),
},
texte: [fiche.resume, ...ressources.flatMap((r) => [r.titre, r.pourquoi])].filter(Boolean).join(' '),
}
}
export function useFicheAdapter() {
return { toMini }
return { toMini, toMiniMot }
}
+88 -2
View File
@@ -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,
})
}
+25 -18
View File
@@ -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"
:data="bifurcationData"
@update:structureId="ficheModalId = $event"
/>
<!-- Détail (B12) : le corps de l'ancienne FicheModalV2 ; une structure liée s'ouvre dans le même panneau -->
<template #detail="{ fiche, filtrer }">
<FicheReseauContenu
:structure-id="String(fiche.id)"
:data="bifurcationData"
:etiquettes-actives="(vf.etat.filtres.tags ?? []).map((t) => `#${t}`)"
@etiquette="(tag) => filtrer('tags', sansDiese(tag))"
@voisin="(id) => vf.ouvrirFiche(id)"
/>
</template>
</VueFiches>
<!-- ═══════════════════════════════════════ BOUTON CHATBOT FLOTTANT (mobile) -->
<button
@@ -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>
+49 -30
View File
@@ -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
}
selectedId.value = id
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}`)
vf.ouvrirFiche(id)
}
// « Voir sur la carte » : la bonne sous-vue (Métropole ou Outre-mer), puis l'épingle
const centrageMetropole = ref<{ id: number; n: number } | null>(null)
watch(() => vf.centrage, (c) => {
if (!c) return
const o = orgParId.value.get(c.id)
const dom = !!o?.territoire && DOM_TOM.includes(o.territoire)
if (vf.etat.sousVue !== (dom ? 'outremer' : 'metropole')) vf.setSousVue(dom ? 'outremer' : 'metropole')
if (!dom) centrageMetropole.value = { id: Number(c.id), n: c.n }
})
// Le bouton retour de /fiche/:id (« Ouvrir la page de la fiche ») revient à la même vue
if (import.meta.client) {
watch(() => route.fullPath, () => {
try {
sessionStorage.setItem('nav_back_filters', new URLSearchParams(route.query as Record<string, string>).toString())
} catch {}
}, { immediate: true })
}
// Surlignage temporaire (5 s) suite à une réponse chatbot
let highlightTimer: ReturnType<typeof setTimeout> | null = null
const prevSelectedId = ref<number | null>(null)
function onHighlightOrgs(ids: (number | string)[]) {
if (!ids.length) return
const firstId = typeof ids[0] === 'string' ? parseInt(ids[0], 10) : ids[0]
if (isNaN(firstId)) return
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>
+535 -570
View File
File diff suppressed because it is too large Load Diff
+2 -12
View File
@@ -1,6 +1,6 @@
{
"name": "FMHY — Sélection Architecte",
"description": "~50 ressources curées depuis FMHY (Free Media Heck Yeah) — pertinentes pour un architecte, un praticien de la transition, un créateur solo.",
"description": "Une quarantaine de ressources légales curées depuis FMHY (Free Media Heck Yeah) — pertinentes pour un architecte, un praticien de la transition, un créateur solo.",
"children": [
{
"name": "IA & Outils cognitifs",
@@ -16,10 +16,6 @@
{
"name": "Lecture & Documentation",
"children": [
{ "name": "Anna's Archive", "url": "https://annas-archive.org/", "desc": "Bibliothèque shadow la plus complète du web. Livres, articles, thèses." },
{ "name": "Sci-Hub", "url": "https://sci-hub.se/", "desc": "Accès libre aux articles scientifiques payants." },
{ "name": "Library Genesis", "url": "https://libgen.is/", "desc": "Livres techniques et académiques en PDF." },
{ "name": "Z-Library", "url": "https://z-lib.id/", "desc": "Bibliothèque numérique massive, interface soignée." },
{ "name": "OpenLibrary (Internet Archive)", "url": "https://openlibrary.org/", "desc": "Prêt numérique gratuit, millions de livres." },
{ "name": "Calibre", "url": "https://calibre-ebook.com/", "desc": "Gestion de bibliothèque numérique, convertisseur de formats." }
]
@@ -27,12 +23,7 @@
{
"name": "Dessin & Modélisation",
"children": [
{ "name": "FreeCAD", "url": "https://www.freecad.org/", "desc": "Modélisation 3D open source, paramétrique. Alternative à Rhino pour usage simple." },
{ "name": "Blender", "url": "https://www.blender.org/", "desc": "3D, rendu, animation. La référence open source." },
{ "name": "Inkscape", "url": "https://inkscape.org/", "desc": "Dessin vectoriel. Alternative à Illustrator." },
{ "name": "GIMP", "url": "https://www.gimp.org/", "desc": "Retouche photo. Alternative à Photoshop." },
{ "name": "Krita", "url": "https://krita.org/", "desc": "Dessin digital et croquis. Excellent pour les concepts." },
{ "name": "LibreOffice Draw", "url": "https://www.libreoffice.org/", "desc": "Diagrammes, plans rapides, sans suite Adobe." }
{ "name": "Krita", "url": "https://krita.org/", "desc": "Dessin digital et croquis. Excellent pour les concepts." }
]
},
{
@@ -92,7 +83,6 @@
"name": "Divers Utiles",
"children": [
{ "name": "Nextcloud", "url": "https://nextcloud.com/", "desc": "Cloud personnel self-hosted. Alternative à Google Drive/Dropbox." },
{ "name": "Joplin", "url": "https://joplinapp.org/", "desc": "Notes chiffrées, sync Nextcloud, open source." },
{ "name": "draw.io / diagrams.net", "url": "https://app.diagrams.net/", "desc": "Diagrammes et schémas, gratuit, pas de compte requis." },
{ "name": "Excalidraw", "url": "https://excalidraw.com/", "desc": "Tableau blanc collaboratif, style hand-drawn, open source." },
{ "name": "Fmhy.net (complet)", "url": "https://fmhy.net/", "desc": "L'arbre complet. Des milliers de ressources organisées par thème." }
+986 -83
View File
File diff suppressed because it is too large Load Diff
+91
View File
@@ -0,0 +1,91 @@
#!/usr/bin/env node
/**
* Contrôle des liens de /outils (B6-M4) — outil de revue, PAS un test : ne fait jamais échouer le build.
* Lit public/data/outils.json (toutes les URL, y compris `urls[]` et `episodes[].url`) et
* public/data/fmhy-curated.json, fait un GET (redirections suivies, délai 15 s, 4 requêtes en parallèle)
* et liste les réponses hors 2xx/3xx.
*
* Un 403/429 d'un site qui bloque les robots n'est pas un lien mort : lire la liste avant de retirer quoi que ce soit.
* Usage : node scripts/check-liens-outils.mjs
*/
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { dirname, join } from 'node:path'
const __dirname = dirname(fileURLToPath(import.meta.url))
const lire = (nom) => JSON.parse(readFileSync(join(__dirname, '..', 'public', 'data', nom), 'utf8'))
const DELAI_MS = 15000
const PARALLELE = 4
const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36'
/** url -> Set des entrées qui la portent. Toute chaîne https:// est prise, quel que soit son champ. */
const urls = new Map()
function ajoute(url, qui) {
if (!urls.has(url)) urls.set(url, new Set())
urls.get(url).add(qui)
}
/** Titre lisible de l'entrée courante : nom / titre / mot, sinon l'id. */
const titreDe = (o) => o.nom ?? o.titre ?? o.name ?? o.mot ?? o.id
function parcourt(valeur, fichier, contexte) {
if (typeof valeur === 'string') {
if (/^https:\/\//i.test(valeur) || /^http:\/\//i.test(valeur)) ajoute(valeur, `${fichier} › ${contexte}`)
} else if (Array.isArray(valeur)) {
for (const v of valeur) parcourt(v, fichier, contexte)
} else if (valeur && typeof valeur === 'object') {
// `lien_texte`-like chaînes ne commencent pas par https:// : seules les vraies URL sont retenues.
const ctx = titreDe(valeur) ? `${contexte ? contexte + ' › ' : ''}${titreDe(valeur)}` : contexte
for (const [, v] of Object.entries(valeur)) parcourt(v, fichier, ctx)
}
}
parcourt(lire('outils.json'), 'outils.json', '')
parcourt(lire('fmhy-curated.json'), 'fmhy-curated.json', '')
async function verifie(url) {
const ctrl = new AbortController()
const minuteur = setTimeout(() => ctrl.abort(), DELAI_MS)
try {
const r = await fetch(url, {
method: 'GET',
redirect: 'follow',
signal: ctrl.signal,
headers: { 'user-agent': UA, accept: 'text/html,application/xhtml+xml,*/*;q=0.8', 'accept-language': 'fr-FR,fr;q=0.9,en;q=0.7' },
})
r.body?.cancel().catch(() => {})
return r.status
} catch (e) {
return e?.name === 'AbortError' ? 'délai' : `erreur (${e?.cause?.code ?? e?.message ?? 'réseau'})`
} finally {
clearTimeout(minuteur)
}
}
const liste = [...urls.keys()]
const resultats = new Map()
let curseur = 0
async function travailleur() {
while (curseur < liste.length) {
const url = liste[curseur++]
resultats.set(url, await verifie(url))
}
}
await Promise.all(Array.from({ length: PARALLELE }, travailleur))
const ko = liste.filter((u) => {
const c = resultats.get(u)
return !(typeof c === 'number' && c >= 200 && c < 400)
})
console.log(`Contrôle de ${liste.length} URL (GET, délai ${DELAI_MS / 1000} s, ${PARALLELE} en parallèle)\n`)
if (ko.length) {
console.log('code'.padEnd(18) + 'URL · entrée')
for (const u of ko) {
console.log(String(resultats.get(u)).padEnd(18) + `${u} · ${[...urls.get(u)].join(' | ')}`)
}
}
console.log(`\n${ko.length} réponse(s) hors 2xx/3xx sur ${liste.length} URL.`)
// Outil de revue : toujours code de sortie 0.
+165
View File
@@ -0,0 +1,165 @@
#!/usr/bin/env node
/**
* Test des données de /outils (B6-M1).
* Garde-fou pour les ajouts futurs (à la main, par M3, ou par un pipe n8n) :
* fiches de l'onglet « Partir de soi », ressources, purge des liens litigieux, URL en https.
* Charge le VRAI utils/echelles.ts via jiti (même patron que test-echelles-nav.mjs).
* Usage : node scripts/test-outils-data.mjs
*/
import { createJiti } from 'jiti'
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { dirname, join } from 'node:path'
const __dirname = dirname(fileURLToPath(import.meta.url))
const jiti = createJiti(import.meta.url)
const { ECHELLES } = await jiti.import(join(__dirname, '..', 'utils', 'echelles.ts'))
const lire = (nom) => JSON.parse(readFileSync(join(__dirname, '..', 'public', 'data', nom), 'utf8'))
const outils = lire('outils.json')
const fmhy = lire('fmhy-curated.json')
const LISTE_NOIRE = [
'annas-archive', 'z-lib', 'libgen', 'sci-hub', 'mobilism', 'zone-ebook', 'rivestream', 'cineby',
'flixer', 'miruro', 'monochrome.tf', 'lucida', 'spotx', 'fitgirl', 'steamrip', '1337x',
]
const FORMATS = ['film', 'vidéo', 'podcast', 'guide PDF', 'livret PDF', 'appli', 'retraite', 'atelier', 'formation', 'association', 'fiches', 'événement', 'récit']
const ACCES = ['libre', 'don libre', 'gratuit avec inscription', 'prix libre', 'financé selon région']
const SOURCES = ['inventaire S 27/09', 'outils.json (avant B6)']
let n = 0
const echecs = []
function verifie(ok, message) {
n++
if (!ok) echecs.push(message)
}
/** Parcourt un JSON à tous les niveaux : (chemin, clé, valeur) pour chaque chaîne. */
function chaines(valeur, chemin, visiteur) {
if (typeof valeur === 'string') visiteur(chemin, valeur)
else if (Array.isArray(valeur)) valeur.forEach((v, i) => chaines(v, `${chemin}[${i}]`, visiteur))
else if (valeur && typeof valeur === 'object') for (const [k, v] of Object.entries(valeur)) chaines(v, `${chemin}.${k}`, visiteur)
}
// ── Fiches ────────────────────────────────────────────────────
const p = outils.partir_de_soi
const fiches = p?.fiches ?? []
const mots = ECHELLES.S.mots
verifie(outils.version === 2, 'version doit valoir 2')
verifie(fiches.length === 7, `7 fiches attendues, ${fiches.length} trouvées`)
verifie(new Set(fiches.map((f) => f.id)).size === fiches.length, 'ids de fiches non uniques')
verifie(
JSON.stringify(fiches.map((f) => f.mot)) === JSON.stringify(mots),
`mots des fiches ≠ ECHELLES.S.mots (dans l'ordre) : ${JSON.stringify(fiches.map((f) => f.mot))}`,
)
const idsFiches = new Set(fiches.map((f) => f.id))
// ── Ressources ────────────────────────────────────────────────
const ressources = p?.ressources ?? []
const idsRes = new Set()
const DATE = /^\d{4}-\d{2}-\d{2}$/
for (const r of ressources) {
const nom = `ressource « ${r.id ?? r.titre ?? '?'} »`
verifie(typeof r.id === 'string' && r.id && !idsRes.has(r.id), `${nom} : id manquant ou en double`)
idsRes.add(r.id)
verifie(typeof r.titre === 'string' && r.titre, `${nom} : titre manquant`)
verifie(FORMATS.includes(r.format), `${nom} : format « ${r.format} » hors liste`)
verifie(ACCES.includes(r.acces), `${nom} : accès « ${r.acces} » hors liste`)
verifie(typeof r.pourquoi === 'string' && r.pourquoi, `${nom} : pourquoi manquant`)
verifie(Array.isArray(r.fiches) && r.fiches.length > 0, `${nom} : fiches vide`)
verifie((r.fiches ?? []).every((id) => idsFiches.has(id)), `${nom} : fiche inconnue dans ${JSON.stringify(r.fiches)}`)
verifie(SOURCES.includes(r.source), `${nom} : source « ${r.source} » hors liste`)
verifie(['socle', 'actualite'].includes(r.couche), `${nom} : couche « ${r.couche} »`)
verifie(['vivant', 'dormant', 'archive'].includes(r.statut), `${nom} : statut « ${r.statut} »`)
verifie(DATE.test(r.verifie_le ?? ''), `${nom} : verifie_le « ${r.verifie_le} » n'est pas AAAA-MM-JJ`)
}
// ── Fiche vide ⇔ sans ressource ───────────────────────────────
for (const f of fiches) {
const nb = ressources.filter((r) => r.fiches?.includes(f.id)).length
if (nb === 0) verifie(typeof f.vide === 'string' && f.vide, `fiche « ${f.id} » sans ressource : \`vide\` doit être renseigné`)
else verifie(f.vide == null, `fiche « ${f.id} » a ${nb} ressource(s) : \`vide\` doit être nul`)
}
// ── URL : https partout, rien de la liste noire ───────────────
for (const [fichier, donnees] of [['outils.json', outils], ['fmhy-curated.json', fmhy]]) {
chaines(donnees, fichier, (chemin, s) => {
const cle = chemin.split('.').pop().replace(/\[\d+\]$/, '')
const estUrl = /^https?:\/\//i.test(s) || ['url', 'urls', 'lien', 'avertissement_lien'].includes(cle)
// `lien` de contribution est un chemin interne (/proposer…) : seul le préfixe http est contrôlé.
if (/^https?:\/\//i.test(s)) {
verifie(s.startsWith('https://'), `${chemin} : URL non https (${s})`)
const bas = s.toLowerCase()
const hit = LISTE_NOIRE.find((d) => bas.includes(d))
verifie(!hit, `${chemin} : domaine de la liste noire « ${hit} » (${s})`)
} else if (estUrl && cle !== 'lien' && s) {
verifie(false, `${chemin} : URL sans https (${s})`)
}
})
}
// Liste noire aussi dans les noms/titres (un lien retiré ne doit pas survivre en texte).
chaines({ outils, fmhy }, 'données', (chemin, s) => {
const bas = s.toLowerCase()
verifie(!bas.includes('tantra'), `${chemin} : contient « tantra »`)
verifie(!bas.includes('the nest'), `${chemin} : contient « The Nest »`)
const hit = ['sci-hub', 'z-library', 'library genesis', "anna's archive", 'fitgirl', 'steamrip', '1337x'].find((d) => bas.includes(d))
verifie(!hit, `${chemin} : contient « ${hit} »`)
})
// ── Structure minimale des autres blocs ───────────────────────
verifie(Array.isArray(outils.conception?.outils) && outils.conception.outils.length > 0, 'conception.outils vide')
verifie(
(outils.conception?.outils ?? []).every((o) => o.mention && DATE.test(o.verifie_le ?? '')),
'conception.outils : `mention` et `verifie_le` obligatoires',
)
verifie(Array.isArray(outils.open_source?.rubriques) && outils.open_source.rubriques.every((r) => r.liens?.length > 0), 'open_source.rubriques : une rubrique vide doit disparaître')
verifie(!!outils.contribution?.texte && !!outils.contribution?.lien && !!outils.contribution?.cta, 'contribution incomplète')
// ── Open source : sélection (B6-M4) ───────────────────────────
// Chaque lien de la sélection est vérifié par une personne (licence, date) ; la liste noire
// piraterie s'applique déjà à toute chaîne d'outils.json (voir plus haut), sélection comprise.
const sel = outils.open_source?.selection
verifie(!!sel && typeof sel.intention === 'string' && sel.intention, 'open_source.selection : intention manquante')
verifie(Array.isArray(sel?.rubriques) && sel.rubriques.length >= 1, 'open_source.selection : au moins une rubrique attendue')
const idsRub = new Set()
for (const r of sel?.rubriques ?? []) {
const rub = `sélection « ${r.id ?? r.titre ?? '?'} »`
verifie(typeof r.id === 'string' && r.id && !idsRub.has(r.id), `${rub} : id manquant ou en double`)
idsRub.add(r.id)
verifie(typeof r.titre === 'string' && r.titre, `${rub} : titre manquant`)
verifie(Array.isArray(r.liens) && r.liens.length > 0, `${rub} : rubrique vide`)
for (const l of r.liens ?? []) {
const nom = `${rub} › ${l.nom ?? '?'}`
verifie(typeof l.nom === 'string' && l.nom, `${nom} : nom manquant`)
verifie(typeof l.url === 'string' && l.url.startsWith('https://'), `${nom} : url https manquante (${l.url})`)
verifie(typeof l.desc === 'string' && l.desc, `${nom} : desc manquante`)
verifie(typeof l.licence === 'string' && l.licence, `${nom} : licence manquante`)
verifie(DATE.test(l.verifie_le ?? ''), `${nom} : verifie_le « ${l.verifie_le} » n'est pas AAAA-MM-JJ`)
const bas = (l.url ?? '').toLowerCase()
const hit = LISTE_NOIRE.find((d) => bas.includes(d))
verifie(!hit, `${nom} : domaine de la liste noire « ${hit} »`)
}
}
// Aucun doublon d'URL dans tout open_source (sélection + recommandés + rubriques fmhy), à la barre finale et à la casse près.
const norm = (u) => u.toLowerCase().replace(/^https?:\/\//, '').replace(/^www\./, '').replace(/\/+$/, '')
const vues = new Map()
const urlsOS = [
...(sel?.rubriques ?? []).flatMap((r) => (r.liens ?? []).map((l) => [`sélection › ${l.nom}`, l.url])),
...(outils.open_source?.recommandes ?? []).filter((o) => o.url).map((o) => [`recommandé › ${o.titre}`, o.url]),
...(outils.open_source?.rubriques ?? []).flatMap((r) => (r.liens ?? []).map((l) => [`fmhy › ${l.nom}`, l.url])),
]
for (const [qui, url] of urlsOS) {
const k = norm(url)
verifie(!vues.has(k), `open_source : URL en double « ${url} » (${vues.get(k)} et ${qui})`)
vues.set(k, qui)
}
if (echecs.length) {
for (const e of echecs) console.log(`FAIL ${e}`)
console.log(`\n${n - echecs.length}/${n} vérifications OK — ${echecs.length} échec(s)`)
process.exit(1)
}
console.log(`${n} vérifications OK`)
+115
View File
@@ -128,5 +128,120 @@ 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)')
// ── Partir de soi : sept mots, ordre de la slide (B6-M2) ──
const MOTS = [
{ id: 'quete-de-sens', mot: 'quête de sens', resume: 'Bifurquer, donner du sens à son métier.' },
{ id: 'gagner-sa-vie', mot: 'gagner sa vie', resume: 'Créer son activité et en vivre.', vide: 'Rien encore.' },
{ id: 'sante-mentale', mot: 'santé mentale', resume: 'Repérer, comprendre, se faire accompagner.' },
{ id: 'naviguer-avec-soi', mot: 'naviguer avec soi', resume: 'Méditation et pratiques d’introspection.' },
]
const RESS = [
{ id: 'film', titre: 'En quête de sens', format: 'film', acces: 'don libre', pourquoi: 'Le film nommé dans le deck.', fiches: ['quete-de-sens'] },
{ id: 'ofqa', titre: 'OFQA', format: 'vidéo', acces: 'libre', pourquoi: 'Diplômés qui ont bifurqué.', fiches: ['quete-de-sens'] },
{ id: 'psc', titre: 'Premiers secours en santé mentale', format: 'formation', acces: 'financé selon région', pourquoi: 'Repérer les signes.', fiches: ['sante-mentale'] },
{ id: 'vipassana', titre: 'Vipassana', format: 'retraite', acces: 'don libre', pourquoi: 'Dix jours de silence.', fiches: ['naviguer-avec-soi'] },
{ id: 'insight', titre: 'Insight Timer', format: 'appli', acces: 'gratuit avec inscription', pourquoi: 'Méditations guidées.', fiches: ['naviguer-avec-soi', 'sante-mentale'] },
]
const parMot = (id) => RESS.filter((r) => r.fiches.includes(id))
const mots = MOTS.map((m) => A.toMiniMot(m, parMot(m.id)))
const cfgMots = {
dimensions: [
{ cle: 'format', libelle: 'Format', variante: 'fonction', valeurs: A.valeursRessources(RESS, 'format') },
{ cle: 'acces', libelle: 'Accès', variante: 'echelle', valeurs: A.valeursRessources(RESS, 'acces') },
],
tris: ['ordre', 'pertinence', 'nom'],
triDefaut: 'ordre',
libellesTri: { ordre: 'Ordre de la slide' },
groupes: [],
etats: ['carte', 'mixte', 'fiches'],
}
const etatM = (query = {}, over = {}) => ({ ...V.lireQuery(query, cfgMots), ...over })
const sansDefaut = { ...cfgMots, triDefaut: undefined, tris: ['pertinence', 'nom'] }
ok(mots[0].kind === 'mot' && mots[0].nom === 'quête de sens' && eq(mots[0].chips, ['film', 'vidéo']) && mots[0].chipsDimension === 'format' && mots[0].geoloc === true, 'mot → kind mot, nom = le mot, chips = formats, geoloc true (pas de carte géographique)')
ok(eq(mots[0].etiquettes, { format: ['film', 'video'], acces: ['don-libre', 'libre'] }), 'mot → étiquettes en slug (sans accent, sans espace) pour l’URL', JSON.stringify(mots[0].etiquettes))
ok(eq(mots[3].etiquettes.acces, ['don-libre', 'gratuit-avec-inscription']) && mots[3].chips.length === 2, 'mot → union des formats et des accès de ses ressources')
ok(mots[1].resume === 'Créer son activité et en vivre.' && eq(mots[1].etiquettes, { format: [], acces: [] }), 'mot sans ressource (gagner sa vie) → résumé gardé, aucune étiquette')
ok(A.toMiniMot({ id: 'x', mot: 'x', resume: '', vide: 'Rien encore. Propose.' }, []).resume === 'Rien encore.', 'mot sans résumé → texte « vide » (première phrase)')
ok(eq(A.valeursRessources(RESS, 'acces').map((v) => v.libelle), ['don libre', 'libre', 'financé selon région', 'gratuit avec inscription']) && A.slugEtiquette('financé selon région') === 'finance-selon-region', 'valeurs de dimension : libellé lisible, valeur en slug, ordre de première apparition')
const idsM = (q, tri) => ids(V.trierFiches(V.filtrerFiches(mots, etatM(q), cfgMots), tri ?? V.triEffectif(etatM(q), cfgMots), etatM(q)))
ok(eq(idsM({}), ['quete-de-sens', 'gagner-sa-vie', 'sante-mentale', 'naviguer-avec-soi']), 'tri ordre : l’ordre du tableau d’entrée, pas A → Z')
ok(eq(ids(V.trierFiches([...mots].reverse(), 'ordre', etatM())), ['naviguer-avec-soi', 'sante-mentale', 'gagner-sa-vie', 'quete-de-sens']), 'tri ordre : stable, suit l’entrée quelle qu’elle soit')
ok(V.triEffectif(etatM(), cfgMots) === 'ordre', 'triDefaut respecté sans filtre')
ok(V.triEffectif(etatM({ q: 'vipassana' }), cfgMots) === 'ordre', 'triDefaut respecté avec une recherche seule (pas de filtre d’étiquette)')
ok(V.triEffectif(etatM({ acces: 'don-libre' }), cfgMots) === 'pertinence', 'pertinence prioritaire dès qu’un filtre est actif, même avec triDefaut')
ok(V.triEffectif(etatM({ acces: 'don-libre', tri: 'ordre' }), cfgMots) === 'ordre', 'tri explicite (tri=ordre dans l’URL) prioritaire')
ok(V.lireQuery({ tri: 'ordre' }, cfgMots).tri === 'ordre', 'tri=ordre lu dans l’URL quand la page l’offre')
ok(V.lireQuery({ tri: 'ordre' }, cfg).tri === null && V.lireQuery({ tri: 'ordre' }, sansDefaut).tri === null, 'tri=ordre ignoré quand la page ne l’offre pas (/ et /agences)')
ok(V.triEffectif(etatM(), sansDefaut) === 'nom' && V.triEffectif(etat0(), cfg) === 'nom', 'sans triDefaut : comportement inchangé (nom)')
ok(V.triEffectif(etatM(), { ...cfgMots, triDefaut: 'recent' }) === 'nom', 'triDefaut absent de tris → ignoré')
ok(eq(idsM({ acces: 'don-libre' }), ['naviguer-avec-soi', 'quete-de-sens']), 'filtre Accès « don libre » : pertinence (ex æquo → A → Z), pas l’ordre de la slide', JSON.stringify(idsM({ acces: 'don-libre' })))
ok(eq(idsM({ q: 'vipassana' }), ['naviguer-avec-soi']), 'recherche « Vipassana » (titre d’une ressource) → le mot « naviguer avec soi »')
ok(eq(idsM({ q: 'VIPASSANA' }), ['naviguer-avec-soi']) && eq(idsM({ q: 'quete' }), ['quete-de-sens']), 'recherche insensible à la casse et aux accents')
const cm = V.compterEtiquettes(mots, etatM({ acces: 'don-libre' }), cfgMots)
ok(cm.format.film === 1 && cm.format.retraite === 1 && cm.format.formation === 0 && cm.acces['don-libre'] === 2 && cm.acces.libre === 1, 'compteurs : format dans le résultat du filtre Accès, accès dans celui des formats', JSON.stringify(cm))
const eMot = V.lireQuery({ tab: 'partir-de-soi', acces: 'don-libre', format: 'film,retraite', fiche: 'quete-de-sens' }, cfgMots)
const qMot = V.ecrireQuery(eMot, cfgMots, { tab: 'partir-de-soi', acces: 'don-libre', fiche: 'quete-de-sens' })
ok(qMot.tab === 'partir-de-soi' && qMot.fiche === 'quete-de-sens' && qMot.acces === 'don-libre' && qMot.format === 'film,retraite', 'tab= et fiche= survivent à l’écriture des filtres')
ok(V.queryAvecFiche({ tab: 'partir-de-soi', vue: 'carte' }, 'sante-mentale').tab === 'partir-de-soi' && V.queryAvecFiche({ tab: 'partir-de-soi', fiche: 'x' }, null).tab === 'partir-de-soi', 'tab= survit à l’ouverture et à la fermeture d’une fiche')
console.log(`\n${n - fail}/${n}`)
process.exit(fail ? 1 : 0)
+1 -1
View File
@@ -89,7 +89,7 @@ export default defineEventHandler(async (event) => {
const query: string = body.query.trim()
const mode: string = body.mode || 'hybrid'
const corpus: string = body.corpus || 'both'
const corpus: string = body.corpus || 'pensees'
const ragUrl: string = (config.ragPeUrl as string) || 'http://localhost:9621'
const auteurSlug: string | null = body.auteur_slug?.trim().toLowerCase() || null
+79 -2
View File
@@ -10,13 +10,14 @@
import type { MiniFiche } from '../composables/useFicheAdapter'
export type EtatVue = 'carte' | 'mixte' | 'fiches'
export type TriFiches = 'pertinence' | 'nom' | 'recent'
export type TriFiches = 'pertinence' | 'nom' | 'recent' | 'ordre'
export const ETATS_VUE: EtatVue[] = ['carte', 'mixte', 'fiches']
export const LIBELLES_TRI: Record<TriFiches, string> = {
pertinence: 'Pertinence',
nom: 'A → Z',
recent: 'Récentes',
ordre: 'Ordre d’origine',
}
export interface ValeurFiltre {
@@ -57,6 +58,10 @@ export interface ConfigVueFiches {
groupes: GroupeDef[]
etats: EtatVue[]
sousVues?: SousVuesCarte
/** Tri sans filtre actif (défaut : `nom`). Doit figurer dans `tris`. */
triDefaut?: TriFiches
/** Libellés de tri propres à la page (sinon `LIBELLES_TRI`). */
libellesTri?: Partial<Record<TriFiches, string>>
}
export interface EtatFiches {
@@ -121,7 +126,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)])
@@ -226,6 +232,7 @@ export function triEffectif(etat: EtatFiches, cfg: ConfigVueFiches): TriFiches {
if (etat.tri) return etat.tri
const filtreEtiquette = Object.values(etat.filtres).some((v) => v.length > 0)
if (filtreEtiquette && cfg.tris.includes('pertinence')) return 'pertinence'
if (cfg.triDefaut && cfg.tris.includes(cfg.triDefaut)) return cfg.triDefaut
return cfg.tris.includes('nom') ? 'nom' : cfg.tris[0] ?? 'nom'
}
@@ -249,6 +256,7 @@ function idNum(f: MiniFiche): number {
export function trierFiches(fiches: MiniFiche[], tri: TriFiches, etat: EtatFiches): MiniFiche[] {
const copie = [...fiches]
if (tri === 'ordre') return copie // ordre du tableau d'entrée (déjà filtré, jamais réordonné)
if (tri === 'nom') return copie.sort(parNom)
if (tri === 'pertinence') {
const score = new Map(copie.map((f) => [f, pertinence(f, etat)]))
@@ -295,3 +303,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('&')
}