Compare commits

...
Author SHA1 Message Date
JulesandClaude Sonnet 5 1838f4b02d feat(b3): M3 — panneau « toutes les fiches » sur / et /agences (desktop + mobile), fiches sans géoloc visibles, ?vue=fiches
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-25 12:17:52 +02:00
JulesandClaude Sonnet 5 5b504b3e04 fix(config): retire vite.cacheDir Windows en dur (dossier C:/ parasite sur Linux)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-25 12:14:05 +02:00
JulesandClaude Sonnet 5 b2b8c13fee feat(b3): M2 — FicheMiniCard + adaptateur Org/Structure, story de test
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-25 12:10:54 +02:00
JulesandClaude Sonnet 5 4b1340f160 feat(b3): M1 — Chip.vue par dimension (échelle outline, fonction pleine, posture safran) + filtres / et /agences
- components/Chip.vue (nouveau) : label/variant(echelle|fonction|posture)/
  active/count/as(button|span)/removable, emits toggle+remove, aria-pressed
  natif sur as=button, croix "Retirer {label}" accessible. Styles 100% tokens
  DS (--primary/--on-primary/--bg-alt/--accent/--on-accent/--focus-ring/
  --dur-fast), hover translateY, focus-visible outline var(--focus-ring),
  transition coupée sous prefers-reduced-motion. Taille compacte 32px de haut
  mesurée (tap ok).
- components/ChipGroup.vue (nouveau) : role="group" + aria-label + flex-wrap
  gap — garantit le wrap propre exigé (jamais une ligne à virgules).
- EchelleFilter.vue, FonctionFilter.vue : rendu remplacé par Chip/ChipGroup,
  props modelValue/counts et événement update:modelValue strictement
  inchangés (NavSidebar.vue n'a rien à changer côté logique). FonctionFilter
  passe le rang de sélection (comme avant) via le prop count de Chip.
- HashtagFilter.vue : seule la liste HASHTAGS (togglable, grammaire fonction)
  passe en Chip/ChipGroup. Les chips FAMILLES gardent leur rendu et leurs
  couleurs propres par famille (hors grammaire échelle/fonction/posture,
  hors périmètre B3 — les changer aurait été une refonte, pas un branchement).
- pages/agences.vue : hashtags par structure (liste sidebar) en
  Chip as="span" variant="fonction" (non cliquables avant, le restent).
- pages/index.vue : la sheet mobile ne passe PAS par NavSidebar/EchelleFilter/
  FonctionFilter — elle a son propre bloc de chips dupliqué (découvert en
  testant le mobile, absent du périmètre écrit du brief). Direction du parti
  design ("chips codées par dimension", mobile-first explicite) : convertie
  en Chip/ChipGroup elle aussi, mêmes handlers toggleEchelle/toggleFonction
  inchangés. Effet de bord positif : le nombre par fonction (déjà calculé,
  jamais affiché sur mobile) apparaît désormais aussi en mobile, à parité
  avec le desktop.
- components/TagBadge.vue supprimé : son seul consommateur, OrgCard.vue,
  n'est importé nulle part dans l'app (grep vérifié) — code mort. OrgCard.vue
  supprimé avec lui plutôt que laissé avec une référence à un composant
  disparu.
- components/TypeBadge.vue : conservé tel quel — ce n'est pas un filtre, et
  sa grammaire couleur par type (7 couleurs Tailwind fixes) est orthogonale
  à échelle/fonction/posture ; le convertir en Chip aurait perdu l'info de
  type ou inventé une 4e variante non validée.

Vérifié : npm run build OK. Chips wrap avec gap en clair/sombre/mobile
(390px, hauteur mesurée 32px). Dark mode : contraste texte/fond conforme
(safran = texte sombre, jamais blanc).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-25 12:03:22 +02:00
JulesandClaude Sonnet 5 0903b8989b feat(b3): M0 — tokens @transformer/ui + thème aep, --nav-* en alias des rôles DS
- package.json : dépendance @transformer/ui (git+https design-system.git)
- nuxt.config.ts : css tokens.css + theme/aep.css chargés avant main.css
- assets/css/main.css : --nav-bg/-bg-alt/-surface/-primary/-accent/-text/
  -text-muted/-font deviennent des alias var(--role) DS ; focus ring sur
  var(--focus-ring). --nav-primary-raw, --nav-primary-solid, --nav-accent-soft
  et --nav-text-on-primary restent des valeurs propres à nav-carte (pas
  d'équivalent DS fiable en sombre — --nav-text-on-primary sert de contraste
  à la fois sur --nav-primary translucide et --nav-primary-solid opaque, deux
  fonds dont la légèreté s'inverse en sombre ; aliaser sur --on-primary DS
  cassait le contraste du logo AEP et de ~10 boutons en sombre, testé au
  pixel et reverti)
- tailwind.config.js : couleurs nav.* pointent vers les variables --nav-*
  (pas de darkMode:'class' ajouté — aucune classe dark: Tailwind en usage)

Vérifié : npm run build OK, diff pixel avant/après < 0.5% sur home + agences
en clair/sombre (bruit = badge Nuxt Devtools, pas les tokens).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-25 11:51:58 +02:00
18 changed files with 947 additions and 195 deletions
+32 -16
View File
@@ -3,28 +3,44 @@
@tailwind utilities;
/* ── Palette NAV V2 — Sobre institutionnel ─────────────────────────────── */
/* --nav-* sont désormais des ALIAS vers les rôles @transformer/ui (tokens.css
+ theme/aep.css, chargés avant ce fichier dans nuxt.config.ts).
--nav-primary-raw, --nav-primary-solid et --nav-text-on-primary restent des
valeurs PROPRES à nav-carte (non aliasées) :
- le DS n'a pas de triplet RGB (--nav-primary-raw) ;
- le --text du DS en sombre (#e8ecf5) ne correspond pas à la teinte bleu
nuit voulue ici pour --nav-primary-solid (#c8d2f0) ;
- --nav-text-on-primary sert de texte de contraste à la fois sur
--nav-primary (translucide, alias DS) ET sur --nav-primary-solid (opaque,
propre à nav-carte) — deux fonds dont la légèreté s'inverse en sombre
(translucide ≈ fond sombre, solid = lavande claire). Le rôle --on-primary
du DS ne colle qu'au premier cas ; l'aliaser cassait le contraste du AEP
logo, des boutons trouver-du-taf, BandeauBas, SimulateurFeature,
FicheDetail (texte clair sur --primary-solid clair en sombre — testé au
pixel le 25/09, illisible). Gardé littéral = identique à la prod avant B3.
Idem --nav-accent-soft, sans équivalent DS. (B3-M0, 2026-09-25) */
:root {
/* Fonds */
--nav-bg: #f8f6f1;
--nav-bg-alt: #eee9df;
--nav-surface: #ffffff;
--nav-bg: var(--bg);
--nav-bg-alt: var(--bg-alt);
--nav-surface: var(--surface);
/* Bleu nuit — 60% d'opacité sur les surfaces */
--nav-primary-raw: 26, 34, 56;
--nav-primary: rgba(26, 34, 56, 0.6);
--nav-primary: var(--primary);
--nav-primary-solid: #1a2238;
/* Accent safran */
--nav-accent: #f5b342;
--nav-accent: var(--accent);
--nav-accent-soft: rgba(245, 179, 66, 0.85);
/* Texte */
--nav-text: #1a2238;
--nav-text-muted: rgba(26, 34, 56, 0.55);
--nav-text: var(--text);
--nav-text-muted: var(--text-muted);
--nav-text-on-primary: #f8f6f1;
/* Typographie */
--nav-font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
--nav-font: var(--font-body);
}
@layer base {
@@ -58,25 +74,25 @@
/* Focus ring accessibilité */
*:focus-visible {
outline: 2px solid var(--nav-accent);
outline: 2px solid var(--focus-ring);
outline-offset: 2px;
}
/* ── Dark mode ──────────────────────────────────────────────────────────── */
.dark {
--nav-bg: #111520;
--nav-bg-alt: #1e2538;
--nav-surface: #1a2238;
--nav-bg: var(--bg);
--nav-bg-alt: var(--bg-alt);
--nav-surface: var(--surface);
--nav-primary-raw: 200, 210, 240;
--nav-primary: rgba(200, 210, 240, 0.15);
--nav-primary: var(--primary);
--nav-primary-solid: #c8d2f0;
--nav-accent: #f5b342;
--nav-accent: var(--accent);
--nav-accent-soft: rgba(245, 179, 66, 0.85);
--nav-text: #e8ecf5;
--nav-text-muted: rgba(200, 210, 240, 0.55);
--nav-text: var(--text);
--nav-text-muted: var(--text-muted);
--nav-text-on-primary: #111520;
}
+170
View File
@@ -0,0 +1,170 @@
<template>
<component
:is="as"
:type="as === 'button' ? 'button' : undefined"
class="chip"
:class="[`chip--${variant}`, { 'chip--active': active }]"
:aria-pressed="as === 'button' ? active : undefined"
@click="as === 'button' && emit('toggle')"
>
<span class="chip__label">{{ label }}</span>
<span v-if="count !== undefined" class="chip__count">{{ count }}</span>
<button
v-if="removable"
type="button"
class="chip__remove"
:aria-label="`Retirer ${label}`"
@click.stop="emit('remove')"
>
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</component>
</template>
<script setup lang="ts">
withDefaults(
defineProps<{
label: string
variant?: 'echelle' | 'fonction' | 'posture'
active?: boolean
count?: number
as?: 'button' | 'span'
removable?: boolean
}>(),
{
variant: 'fonction',
active: false,
count: undefined,
as: 'button',
removable: false,
}
)
const emit = defineEmits<{
toggle: []
remove: []
}>()
</script>
<style scoped>
.chip {
display: inline-flex;
align-items: center;
gap: var(--space-2xs);
padding: var(--space-2xs) var(--space-sm);
border-radius: var(--radius-full);
font-size: var(--fs-xs);
line-height: var(--lh-tight);
font-weight: var(--fw-medium);
border: 1.5px solid transparent;
cursor: pointer;
user-select: none;
min-height: 32px;
box-sizing: border-box;
background: none;
transition: background-color var(--dur-fast) var(--ease-out),
color var(--dur-fast) var(--ease-out),
border-color var(--dur-fast) var(--ease-out),
transform var(--dur-fast) var(--ease-out);
}
@media (prefers-reduced-motion: reduce) {
.chip {
transition: none;
}
}
.chip:hover {
transform: translateY(-1px);
}
.chip:focus-visible {
outline: 2px solid var(--focus-ring);
outline-offset: 2px;
}
/* ── Échelle : outline bleu, fond transparent, actif = plein ────────────── */
.chip--echelle {
border-color: var(--primary);
color: var(--text);
background: transparent;
}
.chip--echelle:hover {
background: var(--primary-hover);
border-color: var(--primary-hover);
color: var(--on-primary);
}
.chip--echelle.chip--active {
background: var(--primary);
border-color: var(--primary);
color: var(--on-primary);
}
.chip--echelle.chip--active:hover {
background: var(--primary-hover);
border-color: var(--primary-hover);
}
/* ── Fonction/hashtag : pleine bleu, inactif = bg-alt ────────────────────── */
.chip--fonction {
border-color: transparent;
background: var(--bg-alt);
color: var(--text);
}
.chip--fonction:hover {
background: var(--primary-hover);
color: var(--on-primary);
}
.chip--fonction.chip--active {
background: var(--primary);
color: var(--on-primary);
}
.chip--fonction.chip--active:hover {
background: var(--primary-hover);
}
/* ── Posture (préparée pour plus tard) : outline safran ──────────────────── */
.chip--posture {
border-color: var(--accent);
color: var(--text);
background: transparent;
}
.chip--posture:hover {
background: var(--accent);
color: var(--on-accent);
}
.chip--posture.chip--active {
background: var(--accent);
border-color: var(--accent);
color: var(--on-accent);
}
.chip__count {
font-size: 0.85em;
opacity: 0.75;
}
.chip__remove {
display: inline-flex;
align-items: center;
justify-content: center;
width: 14px;
height: 14px;
padding: 0;
border: none;
background: transparent;
color: inherit;
opacity: 0.7;
cursor: pointer;
border-radius: var(--radius-full);
}
.chip__remove:hover {
opacity: 1;
}
.chip__remove:focus-visible {
outline: 2px solid var(--focus-ring);
outline-offset: 1px;
}
</style>
+19
View File
@@ -0,0 +1,19 @@
<template>
<div class="chip-group" role="group" :aria-label="ariaLabel">
<slot />
</div>
</template>
<script setup lang="ts">
defineProps<{
ariaLabel: string
}>()
</script>
<style scoped>
.chip-group {
display: flex;
flex-wrap: wrap;
gap: var(--space-2xs) var(--space-xs);
}
</style>
+7 -30
View File
@@ -1,39 +1,16 @@
<template>
<div class="space-y-1.5">
<p class="text-xs font-bold uppercase tracking-widest" style="color: var(--nav-text-muted);">Échelle</p>
<!-- Inline sur 1 ligne — même pattern que FonctionFilter -->
<div class="flex flex-wrap gap-x-4 gap-y-1.5">
<label
<ChipGroup aria-label="Filtrer par échelle">
<Chip
v-for="option in ECHELLES"
:key="option"
class="flex items-center gap-1.5 cursor-pointer select-none transition-opacity"
>
<!-- Case carrée -->
<span
class="flex items-center justify-center shrink-0 transition-all"
style="width: 18px; height: 18px; border: 1.5px solid; border-radius: 3px;"
:style="isSelected(option)
? 'background: var(--nav-primary); border-color: var(--nav-primary); color: #ffffff;'
: 'background: var(--nav-bg-alt); border-color: rgba(26,34,56,0.25); color: transparent;'"
>
<svg v-if="isSelected(option)" width="11" height="11" viewBox="0 0 12 12" fill="none">
<polyline points="2,6 5,9 10,3" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</span>
<!-- Label -->
<span
class="text-sm leading-tight"
:style="isSelected(option) ? 'color: var(--nav-text); font-weight: 600;' : 'color: var(--nav-text);'"
>{{ option }}</span>
<!-- Input réel (masqué) -->
<input
type="checkbox"
class="sr-only"
:checked="isSelected(option)"
@change="toggle(option)"
:label="option"
variant="echelle"
:active="isSelected(option)"
@toggle="toggle(option)"
/>
</label>
</div>
</ChipGroup>
</div>
</template>
+103
View File
@@ -0,0 +1,103 @@
<template>
<button
type="button"
class="fiche-mini-card"
:aria-label="fiche.nom"
@click="emit('open')"
>
<span class="fiche-mini-card__nom">{{ fiche.nom }}</span>
<span v-if="fiche.resume" class="fiche-mini-card__resume">{{ fiche.resume }}</span>
<ChipGroup
v-if="fiche.chips.length || fiche.echelle"
:aria-label="`Étiquettes de ${fiche.nom}`"
class="fiche-mini-card__chips"
>
<Chip v-if="fiche.echelle" as="span" variant="echelle" :label="fiche.echelle" />
<Chip v-for="chip in fiche.chips" :key="chip" as="span" variant="fonction" :label="chip" />
</ChipGroup>
<span v-if="!fiche.geoloc" class="fiche-mini-card__no-geo">Sans localisation</span>
</button>
</template>
<script setup lang="ts">
import type { MiniFiche } from '~/composables/useFicheAdapter'
defineProps<{
fiche: MiniFiche
}>()
const emit = defineEmits<{
open: []
}>()
</script>
<style scoped>
.fiche-mini-card {
display: flex;
flex-direction: column;
align-items: flex-start;
width: 100%;
padding: var(--space-sm);
border: 1px solid transparent;
border-radius: var(--radius-md);
background: var(--surface);
color: var(--text);
text-align: left;
cursor: pointer;
font-family: inherit;
box-sizing: border-box;
transition: background-color var(--dur-fast) var(--ease-out),
box-shadow var(--dur-fast) var(--ease-out);
}
@media (prefers-reduced-motion: reduce) {
.fiche-mini-card {
transition: none;
}
}
.fiche-mini-card:hover {
background: var(--surface-2);
box-shadow: var(--shadow-sm);
}
.fiche-mini-card:focus-visible {
outline: 2px solid var(--focus-ring);
outline-offset: 2px;
}
.fiche-mini-card__nom {
width: 100%;
font-size: var(--fs-sm);
font-weight: var(--fw-semibold);
line-height: var(--lh-tight);
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.fiche-mini-card__resume {
width: 100%;
margin-top: var(--space-2xs);
font-size: var(--fs-xs);
line-height: var(--lh-base);
color: var(--text-muted);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.fiche-mini-card__chips {
margin-top: var(--space-xs);
}
.fiche-mini-card__no-geo {
margin-top: var(--space-2xs);
font-size: var(--fs-xs);
color: var(--text-subtle);
}
</style>
+135
View File
@@ -0,0 +1,135 @@
<!--
FichesPanel — B3-M3
Panneau « toutes les fiches » : reçoit une liste déjà filtrée (recherche +
filtres en cours côté page), l'affiche en grille de FicheMiniCard, et
expose ses propres chips de filtrage branchées sur le MÊME état que la
page appelante (pas un second état — voir vérification du brief).
Ne crée pas de second champ de recherche : `query` sert uniquement à
nuancer le message d'état vide.
-->
<template>
<div class="fiches-panel">
<div class="fiches-panel__head">
<p class="fiches-panel__intention">{{ intention }}</p>
<ChipGroup v-if="hashtags.length" aria-label="Filtrer les fiches" class="fiches-panel__chips">
<Chip
v-for="tag in hashtags"
:key="tag"
:label="tag"
variant="fonction"
:active="selected.includes(tag)"
@toggle="toggle(tag)"
/>
</ChipGroup>
<div class="fiches-panel__count">
{{ fiches.length }} fiche{{ fiches.length > 1 ? 's' : '' }}
</div>
</div>
<div class="fiches-panel__body">
<p v-if="fiches.length === 0" class="fiches-panel__empty">
Aucun résultat<template v-if="query"> pour « {{ query }} »</template>.
</p>
<div v-else class="fiches-panel__grid">
<FicheMiniCard
v-for="fiche in fiches"
:key="`${fiche.kind}-${fiche.id}`"
:fiche="fiche"
@open="emit('open', fiche)"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { MiniFiche } from '~/composables/useFicheAdapter'
const props = defineProps<{
fiches: MiniFiche[]
hashtags: string[]
selected: string[]
query: string
intention: string
}>()
const emit = defineEmits<{
'update:selected': [value: string[]]
open: [fiche: MiniFiche]
}>()
function toggle(tag: string) {
if (props.selected.includes(tag)) {
emit('update:selected', props.selected.filter((t) => t !== tag))
} else {
emit('update:selected', [...props.selected, tag])
}
}
</script>
<style scoped>
.fiches-panel {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.fiches-panel__head {
flex-shrink: 0;
padding: var(--space-sm) var(--space-md);
border-bottom: 1px solid var(--border);
background: var(--surface);
}
.fiches-panel__intention {
margin: 0 0 var(--space-xs);
font-size: var(--fs-sm);
line-height: var(--lh-base);
color: var(--text-muted);
}
.fiches-panel__count {
margin-top: var(--space-xs);
font-size: var(--fs-xs);
font-weight: var(--fw-semibold);
text-transform: uppercase;
letter-spacing: 0.02em;
color: var(--text-subtle);
}
.fiches-panel__body {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: var(--space-md);
}
.fiches-panel__empty {
margin: var(--space-xl) 0;
text-align: center;
font-size: var(--fs-sm);
color: var(--text-muted);
}
.fiches-panel__grid {
display: grid;
grid-template-columns: 1fr;
gap: var(--space-sm);
}
@media (min-width: 768px) {
.fiches-panel__grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (min-width: 1280px) {
.fiches-panel__grid {
grid-template-columns: repeat(3, 1fr);
}
}
</style>
+9 -24
View File
@@ -1,32 +1,17 @@
<template>
<div class="space-y-1.5">
<p class="text-xs font-bold uppercase tracking-widest" style="color: var(--nav-text-muted);">Fonction</p>
<div class="space-y-1">
<button
<ChipGroup aria-label="Filtrer par fonction">
<Chip
v-for="fn in FONCTIONS"
:key="fn"
@click="toggle(fn)"
:aria-pressed="modelValue.includes(fn)"
class="flex items-center gap-2.5 w-full rounded px-1 py-0.5 transition-all text-left hover:opacity-80"
:style="modelValue.includes(fn) ? 'background: rgba(26,34,56,0.06);' : ''"
>
<!-- Case : affiche le rang de priorité si actif, sinon le nombre d'orgs -->
<span
class="flex items-center justify-center shrink-0 text-xs font-bold transition-all"
style="width: 24px; height: 24px; border: 1.5px solid; border-radius: 4px;"
:style="modelValue.includes(fn)
? 'background: var(--nav-primary); border-color: var(--nav-primary); color: var(--nav-text-on-primary);'
: 'background: var(--nav-bg-alt); border-color: var(--nav-bg-alt); color: var(--nav-text-muted);'"
>
{{ modelValue.includes(fn) ? (modelValue.indexOf(fn) + 1) : (counts[fn] ?? 0) }}
</span>
<!-- Label -->
<span
class="text-sm leading-tight"
:style="modelValue.includes(fn) ? 'color: var(--nav-text); font-weight: 600;' : 'color: var(--nav-text);'"
>{{ fn }}</span>
</button>
</div>
:label="fn"
variant="fonction"
:active="modelValue.includes(fn)"
:count="modelValue.includes(fn) ? (modelValue.indexOf(fn) + 1) : (counts[fn] ?? 0)"
@toggle="toggle(fn)"
/>
</ChipGroup>
<p v-if="modelValue.length" class="text-xs pt-0.5" style="color: var(--nav-text-muted);">
{{ modelValue.length }} actif{{ modelValue.length > 1 ? 's' : '' }}
<button @click="emit('update:modelValue', [])" class="ml-2 underline hover:opacity-70">Effacer</button>
+9 -11
View File
@@ -1,6 +1,6 @@
<template>
<div class="hashtag-filter" style="padding: 8px 12px; background: var(--nav-surface);">
<!-- Filtres famille -->
<!-- Filtres famille — couleurs propres par famille (hors grammaire dimension Chip, non touché B3-M1) -->
<div style="margin-bottom: 6px;">
<span class="filter-label">FAMILLES</span>
<div class="chips-row">
@@ -25,17 +25,16 @@
style="font-size: 0.7rem; color: var(--nav-text-muted); background: none; border: none; cursor: pointer; text-decoration: underline; padding: 0;"
>{{ hashtagsVisible ? 'Replier' : 'Afficher (' + props.allHashtags.length + ')' }}</button>
</div>
<div v-if="hashtagsVisible" class="chips-row">
<span
<ChipGroup v-if="hashtagsVisible" aria-label="Filtrer par hashtag">
<Chip
v-for="tag in props.allHashtags"
:key="tag"
class="chip chip-small"
:style="selectedHashtags.includes(tag)
? 'background: var(--nav-primary); color: var(--nav-text-on-primary); font-weight: 600;'
: 'background: var(--nav-bg-alt); color: var(--nav-text-muted);'"
@click="toggleHashtag(tag)"
>{{ tag }}</span>
</div>
:label="tag"
variant="fonction"
:active="selectedHashtags.includes(tag)"
@toggle="toggleHashtag(tag)"
/>
</ChipGroup>
</div>
</div>
</template>
@@ -93,5 +92,4 @@ function toggleHashtag(tag: string) {
transition: all 0.15s;
user-select: none;
}
.chip-small { font-size: 0.7rem; padding: 2px 8px; }
</style>
-45
View File
@@ -1,45 +0,0 @@
<template>
<NuxtLink
:to="`/fiche/${org.Id}`"
class="block bg-white rounded-xl shadow-sm border border-warm-200 hover:shadow-md hover:border-sage-300 transition-all duration-200 p-5"
>
<div class="flex items-start justify-between gap-3 mb-2">
<h2 class="font-semibold text-gray-900 text-base leading-snug">{{ org.nom }}</h2>
<TypeBadge v-if="org.type_org" :type="org.type_org" class="shrink-0 mt-0.5" />
</div>
<p class="text-gray-600 text-sm leading-relaxed mb-3 line-clamp-2">
{{ org.description }}
</p>
<div v-if="tags.length" class="flex flex-wrap gap-1.5">
<TagBadge
v-for="tag in tags"
:key="tag"
:tag="tag"
@click="$emit('filter-tag', tag)"
/>
</div>
</NuxtLink>
</template>
<script setup lang="ts">
const props = defineProps<{
org: {
Id: number
nom: string
type_org?: string
description?: string
tags?: string
lien?: string
}
}>()
defineEmits<{ 'filter-tag': [tag: string] }>()
const tags = computed(() =>
props.org.tags
? props.org.tags.split(',').map((t) => t.trim()).filter(Boolean)
: []
)
</script>
-11
View File
@@ -1,11 +0,0 @@
<template>
<span
class="inline-block px-2 py-0.5 rounded-full text-xs font-medium bg-sage-100 text-sage-700 border border-sage-200 cursor-pointer hover:bg-sage-200 transition-colors"
@click.prevent="$emit('click', tag)"
>{{ tag }}</span>
</template>
<script setup lang="ts">
defineProps<{ tag: string }>()
defineEmits<{ click: [tag: string] }>()
</script>
+84
View File
@@ -0,0 +1,84 @@
/**
* useFicheAdapter — B3-M2
* Adaptateur léger Org | StructureV2 → MiniFiche, format commun consommé par
* FicheMiniCard / FichesPanel. Aucune mutation des types sources.
*
* Repli résumé (brief B3, le champ `resume_court` du batch B2b n'existe pas
* encore) : première phrase du premier champ non vide parmi une liste de
* candidats ordonnée par type, tronquée proprement à ~110 caractères sur un
* mot (jamais au milieu).
*/
import type { Org } from '~/types/org'
import type { StructureV2 } from '~/types/structure-v2'
export interface MiniFiche {
id: string | number
nom: string
resume: string
chips: string[]
echelle?: string
geoloc: boolean
kind: 'org' | 'structure'
}
const RESUME_MAX = 110
/** Première phrase d'un texte (coupe sur ". "/"! "/"? " ou fin de chaîne). */
function firstSentence(text: string): string {
const match = text.match(/^.*?[.!?](?=\s|$)/)
return (match ? match[0] : text).trim()
}
/** Tronque proprement sur un mot, jamais au milieu. */
function truncateOnWord(text: string, max = RESUME_MAX): string {
if (text.length <= max) return text
const slice = text.slice(0, max)
const lastSpace = slice.lastIndexOf(' ')
const cut = lastSpace > max * 0.4 ? slice.slice(0, lastSpace) : slice
return `${cut.trimEnd()}…`
}
function buildResume(candidates: Array<string | null | undefined>): string {
const source = candidates.find((c) => !!c && c.trim().length > 0)
if (!source) return ''
return truncateOnWord(firstSentence(source.trim()))
}
function isStructure(source: Org | StructureV2): source is StructureV2 {
return 'famille_principale' in source
}
function orgFonctions(org: Org): string[] {
return (org.tags_fonction ?? '')
.split(',')
.map((f) => f.trim())
.filter(Boolean)
.slice(0, 3)
}
export function useFicheAdapter() {
function toMini(source: Org | StructureV2): MiniFiche {
if (isStructure(source)) {
return {
id: source.id,
nom: source.nom,
resume: buildResume([source.description_courte, source.description_longue]),
chips: (source.hashtags ?? []).slice(0, 3),
geoloc: source.latitude != null && source.longitude != null,
kind: 'structure',
}
}
return {
id: source.Id,
nom: source.nom,
resume: buildResume([source.description_enrichie, source.description]),
chips: orgFonctions(source),
echelle: source.echelle,
geoloc: source.latitude != null && source.longitude != null,
kind: 'org',
}
}
return { toMini }
}
+2 -1
View File
@@ -1,6 +1,8 @@
export default defineNuxtConfig({
modules: ['@nuxtjs/tailwindcss'],
css: [
'@transformer/ui/tokens.css',
'@transformer/ui/theme/aep.css',
'~/assets/css/main.css',
'leaflet/dist/leaflet.css',
'leaflet.markercluster/dist/MarkerCluster.css',
@@ -36,7 +38,6 @@ export default defineNuxtConfig({
ssr: true,
vite: {
cacheDir: 'C:/Users/jules/AppData/Local/nav-carte-vite-cache',
optimizeDeps: {
include: ['leaflet', 'leaflet.markercluster', 'd3'],
},
+6
View File
@@ -8,6 +8,7 @@
"hasInstallScript": true,
"dependencies": {
"@headlessui/vue": "^1.7.23",
"@transformer/ui": "git+https://git.trans-former.fr/jules/design-system.git",
"@types/leaflet": "^1.9.21",
"@types/leaflet.markercluster": "^1.5.6",
"d3": "^7.9.0",
@@ -3625,6 +3626,11 @@
"vue": "^2.7.0 || ^3.0.0"
}
},
"node_modules/@transformer/ui": {
"version": "0.1.0",
"resolved": "git+https://git.trans-former.fr/jules/design-system.git#057de406c804315e09d28fb26f10941d61e461b1",
"license": "UNLICENSED"
},
"node_modules/@tybys/wasm-util": {
"version": "0.10.1",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
+1
View File
@@ -11,6 +11,7 @@
},
"dependencies": {
"@headlessui/vue": "^1.7.23",
"@transformer/ui": "git+https://git.trans-former.fr/jules/design-system.git",
"@types/leaflet": "^1.9.21",
"@types/leaflet.markercluster": "^1.5.6",
"d3": "^7.9.0",
+104 -17
View File
@@ -2,7 +2,15 @@
<div class="flex h-full overflow-hidden" style="background: var(--nav-bg);">
<!-- ═══════════════════════════════════════ SIDEBAR DESKTOP (>= 1024px) -->
<div class="hidden lg:block overflow-y-auto" style="width: 320px; min-width: 320px; flex-shrink: 0; border-right: 1px solid var(--nav-bg-alt); height: 100%;">
<!-- Masquée en vue « Toutes les fiches » (B3-M3, décision Jules axe 2) :
le panneau EST la liste (+ ses propres chips hashtags), la sidebar
ferait doublon. IntentionBanner reste monté (Teleport, insensible au
v-show de son conteneur). -->
<div
v-show="desktopMapView !== 'fiches'"
class="hidden lg:block overflow-y-auto"
style="width: 320px; min-width: 320px; flex-shrink: 0; border-right: 1px solid var(--nav-bg-alt); height: 100%;"
>
<!-- IntentionBanner s'auto-affiche via Teleport (overlay plein ecran) -->
<IntentionBanner />
@@ -87,14 +95,15 @@
/>
</div>
<div class="mt-0.5 text-xs" style="color: var(--nav-text-muted);">{{ structure.type_principal }} - {{ structure.ville }}</div>
<div v-if="structure.hashtags.length" class="mt-1 flex flex-wrap gap-1">
<span
<ChipGroup v-if="structure.hashtags.length" class="mt-1" :aria-label="`Hashtags de ${structure.nom}`">
<Chip
v-for="tag in structure.hashtags.slice(0, 2)"
:key="tag"
class="text-xs"
style="color: var(--nav-text-muted);"
>{{ tag }}</span>
</div>
as="span"
variant="fonction"
:label="tag"
/>
</ChipGroup>
</div>
</div>
@@ -112,22 +121,29 @@
:style="desktopMapView === 'metropole'
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
@click="desktopMapView = 'metropole'"
@click="setDesktopView('metropole')"
>Métropolitain</button>
<button
class="px-5 py-2 text-sm font-medium transition-colors"
:style="desktopMapView === 'outremer'
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
@click="desktopMapView = 'outremer'"
@click="setDesktopView('outremer')"
>Outre-mer</button>
<button
class="px-5 py-2 text-sm font-medium transition-colors"
:style="desktopMapView === 'graphe'
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
@click="desktopMapView = 'graphe'"
@click="setDesktopView('graphe')"
>Vue graphique</button>
<button
class="px-5 py-2 text-sm font-medium transition-colors"
:style="desktopMapView === 'fiches'
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
@click="setDesktopView('fiches')"
>Toutes les fiches</button>
</div>
<!-- Carte Métropole desktop -->
@@ -194,31 +210,53 @@
@applyHashtag="(tag) => { if (!selectedHashtags.includes(tag)) selectedHashtags = [...selectedHashtags, tag] }"
/>
</div>
<!-- Panneau « Toutes les fiches » desktop (B3-M3) — la sidebar est
masquée dans cette vue (décision Jules axe 2), le panneau EST
la liste, avec ses propres chips hashtags sur le même état. -->
<div v-show="desktopMapView === 'fiches'" class="flex-1 flex flex-col overflow-hidden">
<FichesPanel
:fiches="allStructuresMini"
:hashtags="allHashtags"
:selected="selectedHashtags"
:query="search"
intention="Toutes les agences et réseaux du répertoire, filtrables par hashtag."
@update:selected="selectedHashtags = $event"
@open="(fiche) => onSelectStructure(String(fiche.id))"
/>
</div>
</div>
<!-- ── VUE MOBILE : Onglets Métro/Outre-mer/Graphique + sheet swipable ── -->
<!-- ── VUE MOBILE : Onglets Métro/Outre-mer/Graphique/Fiches + sheet swipable ── -->
<div class="lg:hidden shrink-0 flex" style="background: var(--nav-surface); border-bottom: 1px solid var(--nav-bg-alt);">
<button
class="flex-1 py-2 text-sm font-medium transition-colors"
:style="mobileMapView === 'metropole'
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
@click="mobileMapView = 'metropole'"
@click="setMobileView('metropole')"
>Métropolitain</button>
<button
class="flex-1 py-2 text-sm font-medium transition-colors"
:style="mobileMapView === 'outremer'
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
@click="mobileMapView = 'outremer'"
@click="setMobileView('outremer')"
>Outre-mer</button>
<button
class="flex-1 py-2 text-sm font-medium transition-colors"
:style="mobileMapView === 'graphe'
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
@click="mobileMapView = 'graphe'"
@click="setMobileView('graphe')"
>Graphe</button>
<button
class="flex-1 py-2 text-sm font-medium transition-colors"
:style="mobileMapView === 'fiches'
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
@click="setMobileView('fiches')"
>Fiches</button>
</div>
<div class="lg:hidden flex-1 relative overflow-hidden">
@@ -272,8 +310,11 @@
</ClientOnly>
</div>
<!-- Fond neutre pour l'onglet « Fiches » (pas de carte dessous) -->
<div v-show="mobileMapView === 'fiches'" class="absolute inset-0" style="background: var(--nav-bg-alt);" />
<!-- Bottom sheet swipable (masqué en vue graphique pour ne pas occulter le canvas) -->
<ClientOnly v-if="mobileMapView !== 'graphe'">
<ClientOnly v-if="mobileMapView !== 'graphe' && mobileMapView !== 'fiches'">
<MobileSheet :resultCount="filtered.length" :pending="pending">
<!-- Bandeau intention mobile -->
<div class="px-3 py-2" style="background: var(--bifurc-banner-bg, #faf8f5); border-bottom: 1px solid var(--bifurc-banner-border, #e0d8cc);">
@@ -364,6 +405,21 @@
</div>
</MobileSheet>
</ClientOnly>
<!-- Bottom sheet — onglet « Fiches » mobile (B3-M3), contenu dédié -->
<ClientOnly v-else-if="mobileMapView === 'fiches'">
<MobileSheet :resultCount="filtered.length" :pending="pending">
<FichesPanel
:fiches="allStructuresMini"
:hashtags="allHashtags"
:selected="selectedHashtags"
:query="search"
intention="Toutes les agences et réseaux du répertoire, filtrables par hashtag."
@update:selected="selectedHashtags = $event"
@open="(fiche) => onSelectStructureMobile(String(fiche.id))"
/>
</MobileSheet>
</ClientOnly>
</div>
</main>
@@ -454,15 +510,41 @@ function familleColor(f: number): string {
}
// ── État UI ────────────────────────────────────────────────────────────────
type MapView = 'metropole' | 'outremer' | 'graphe' | 'fiches'
const route = useRoute()
const router = useRouter()
const selectedId = ref<string | null>(null)
const hoveredId = ref<string | null>(null)
const ficheModalOpen = ref(false)
const ficheModalId = ref<string | null>(null)
const chatbotOpen = ref(false)
const mobileMapView = ref<'metropole' | 'outremer' | 'graphe'>('metropole')
const desktopMapView = ref<'metropole' | 'outremer' | 'graphe'>('metropole')
const mobileMapView = ref<MapView>((route.query.vue as string) === 'fiches' ? 'fiches' : 'metropole')
const desktopMapView = ref<MapView>((route.query.vue as string) === 'fiches' ? 'fiches' : 'metropole')
const missionOpen = ref(false)
// Sync URL — B3-M3 : pose/retire ?vue=fiches uniquement (agences.vue n'avait
// aucune synchronisation URL avant B3 pour search/famille/hashtags/vue ; hors
// périmètre de cette mission de l'étendre, seul ?vue=fiches est ajouté ici).
function setDesktopView(v: MapView) {
desktopMapView.value = v
syncViewUrl()
}
function setMobileView(v: MapView) {
mobileMapView.value = v
syncViewUrl()
}
function syncViewUrl() {
const q: Record<string, string> = { ...(route.query as Record<string, string>) }
if (desktopMapView.value === 'fiches' || mobileMapView.value === 'fiches') {
q.vue = 'fiches'
} else {
delete q.vue
}
router.replace({ query: Object.keys(q).length ? q : undefined })
}
onMounted(() => {
try {
if (!localStorage.getItem('aep_reseaux_seen')) {
@@ -555,6 +637,11 @@ function resetFilters() {
// Pour simplifier : toutes les structures (la carte gère les sans-coords)
const metropoleStructures = computed<StructureV2[]>(() => filtered.value)
// Panneau « Toutes les fiches » (B3-M3) — mêmes structures que `filtered`,
// sans filtrer par géolocalisation (adaptateur mutualisé avec `/`).
const { toMini } = useFicheAdapter()
const allStructuresMini = computed(() => filtered.value.map((s) => toMini(s)))
// Outre-mer : pas de structures V2 DOM-TOM pour l'instant - garder le composant existant vide
// OutremerMap attend le format Org legacy - on passe un tableau vide
const outremerOrgsLegacy = computed(() => [])
+150
View File
@@ -0,0 +1,150 @@
<!--
Page de dev — story de test FicheMiniCard (B3-M2)
Sert uniquement à la vérification visuelle. Aucun lien vers cette page
nulle part dans l'app (pas dans app.vue, pas dans les pages) : navigation
directe par URL uniquement (/dev/fiche-mini-card).
-->
<template>
<div class="story">
<h1 class="story__title">FicheMiniCard — story de test (B3-M2)</h1>
<section class="story__section">
<h2 class="story__heading">Org — cas nominal</h2>
<div class="story__grid">
<FicheMiniCard :fiche="orgNominal" @open="log('org-nominal')" />
</div>
</section>
<section class="story__section">
<h2 class="story__heading">Structure — cas nominal</h2>
<div class="story__grid">
<FicheMiniCard :fiche="structureNominal" @open="log('structure-nominal')" />
</div>
</section>
<section class="story__section">
<h2 class="story__heading">Cas limites</h2>
<div class="story__grid">
<FicheMiniCard :fiche="nomLong" @open="log('nom-long')" />
<FicheMiniCard :fiche="sansDescription" @open="log('sans-description')" />
<FicheMiniCard :fiche="sansChips" @open="log('sans-chips')" />
<FicheMiniCard :fiche="sansGeoloc" @open="log('sans-geoloc')" />
</div>
</section>
<section class="story__section">
<h2 class="story__heading">Dernier événement « open »</h2>
<p class="story__log">{{ lastOpen ?? '(aucun clic pour le moment)' }}</p>
</section>
</div>
</template>
<script setup lang="ts">
import type { MiniFiche } from '~/composables/useFicheAdapter'
definePageMeta({ layout: false })
const lastOpen = ref<string | null>(null)
function log(label: string) {
lastOpen.value = label
}
const orgNominal: MiniFiche = {
id: 1,
nom: 'Réseau Bâti Vivant',
resume: 'Un réseau national d\'accompagnement juridique et technique pour les collectifs d\'auto-construction.',
chips: ['Juridique', 'Technique'],
echelle: 'National',
geoloc: true,
kind: 'org',
}
const structureNominal: MiniFiche = {
id: 'f1-rotor',
nom: 'Rotor',
resume: 'Rotor invente en Belgique le protocole de la déconstruction sélective comme geste politique contre la culture du tout-neuf.',
chips: ['#reemploi-structurel', '#plateforme-materiaux'],
geoloc: true,
kind: 'structure',
}
const nomLong: MiniFiche = {
id: 2,
nom: 'Collectif Régional de Réemploi et d\'Accompagnement des Chantiers Participatifs du Grand Ouest',
resume: 'Nom volontairement long pour vérifier l\'ellipse sur une ligne.',
chips: ['Chantier'],
echelle: 'Régional',
geoloc: true,
kind: 'org',
}
const sansDescription: MiniFiche = {
id: 3,
nom: 'Structure sans description',
resume: '',
chips: ['Formation'],
echelle: 'Local',
geoloc: true,
kind: 'org',
}
const sansChips: MiniFiche = {
id: 4,
nom: 'Structure sans chips',
resume: 'Une organisation dont aucune fonction ni hashtag n\'est renseigné dans la source.',
chips: [],
geoloc: true,
kind: 'org',
}
const sansGeoloc: MiniFiche = {
id: 5,
nom: 'Structure sans localisation',
resume: 'Une fiche nationale sans latitude ni longitude — invisible sur la carte, visible ici.',
chips: ['Administratif', 'Comptabilité'],
echelle: 'National',
geoloc: false,
kind: 'org',
}
</script>
<style scoped>
.story {
max-width: 1100px;
margin: 0 auto;
padding: var(--space-lg);
background: var(--bg);
color: var(--text);
font-family: var(--font-body);
}
.story__title {
font-size: var(--fs-xl);
font-weight: var(--fw-bold);
margin-bottom: var(--space-lg);
}
.story__section {
margin-bottom: var(--space-xl);
}
.story__heading {
font-size: var(--fs-sm);
font-weight: var(--fw-semibold);
text-transform: uppercase;
letter-spacing: 0.02em;
color: var(--text-muted);
margin-bottom: var(--space-sm);
}
.story__grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: var(--space-sm);
}
.story__log {
font-size: var(--fs-sm);
color: var(--text-muted);
}
</style>
+104 -30
View File
@@ -40,7 +40,7 @@
Mode dev — données seed
</div>
<!-- ── VUE DESKTOP : Onglets Métropole / Outre-mer ── -->
<!-- ── VUE DESKTOP : Onglets Métropole / Outre-mer / Toutes les fiches ── -->
<div class="hidden lg:flex lg:flex-1 lg:flex-col lg:overflow-hidden">
<!-- Barre onglets desktop -->
<div class="shrink-0 flex" style="background: var(--nav-surface); border-bottom: 1px solid var(--nav-bg-alt);">
@@ -49,15 +49,22 @@
:style="desktopMapView === 'metropole'
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
@click="desktopMapView = 'metropole'"
@click="setDesktopView('metropole')"
>Métropolitain</button>
<button
class="px-5 py-2 text-sm font-medium transition-colors"
:style="desktopMapView === 'outremer'
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
@click="desktopMapView = 'outremer'"
@click="setDesktopView('outremer')"
>Outre-mer</button>
<button
class="px-5 py-2 text-sm font-medium transition-colors"
:style="desktopMapView === 'fiches'
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
@click="setDesktopView('fiches')"
>Toutes les fiches</button>
</div>
<!-- Carte Métropole desktop -->
@@ -94,26 +101,58 @@
</div>
<ChatbotPlaceholder @highlightOrgs="onHighlightOrgs" />
</div>
<!-- Panneau « Toutes les fiches » desktop (B3-M3) — remplace la carte,
fiches sans géoloc incluses (gain principal de cette mission) -->
<div v-show="desktopMapView === 'fiches'" class="flex-1 flex flex-col overflow-hidden">
<div
class="shrink-0 px-4 py-2"
style="border-bottom: 1px solid var(--nav-bg-alt); background: var(--nav-surface);"
>
<button
type="button"
class="text-xs font-medium underline"
style="color: var(--nav-text-muted);"
@click="setDesktopView('metropole')"
>← Carte</button>
</div>
<FichesPanel
:fiches="allFichesMini"
:hashtags="[...FONCTIONS]"
:selected="fonctions"
:query="search"
intention="Toutes les structures d'entraide, y compris celles sans adresse sur la carte. Cochez une fonction pour filtrer."
@update:selected="onFonctions"
@open="(fiche) => onSelectOrg(Number(fiche.id))"
/>
</div>
</div>
<!-- ── VUE MOBILE : Onglets Métro/Outre-mer + carte pleine hauteur + sheet swipable ── -->
<!-- Onglets Métropolitain / Outre-mer -->
<!-- Onglets Métropolitain / Outre-mer / Toutes les fiches -->
<div class="lg:hidden shrink-0 flex" style="background: var(--nav-surface); border-bottom: 1px solid var(--nav-bg-alt);">
<button
class="flex-1 py-2 text-sm font-medium transition-colors"
:style="mobileMapView === 'metropole'
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
@click="mobileMapView = 'metropole'"
@click="setMobileView('metropole')"
>Métropolitain</button>
<button
class="flex-1 py-2 text-sm font-medium transition-colors"
:style="mobileMapView === 'outremer'
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
@click="mobileMapView = 'outremer'"
@click="setMobileView('outremer')"
>Outre-mer</button>
<button
class="flex-1 py-2 text-sm font-medium transition-colors"
:style="mobileMapView === 'fiches'
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
@click="setMobileView('fiches')"
>Fiches</button>
</div>
<div class="lg:hidden flex-1 relative overflow-hidden">
@@ -154,9 +193,12 @@
</ClientOnly>
</div>
<!-- Bottom sheet swipable (Métropole et Outre-mer) -->
<!-- Fond neutre pour l'onglet « Fiches » (pas de carte dessous) -->
<div v-show="mobileMapView === 'fiches'" class="absolute inset-0" style="background: var(--nav-bg-alt);" />
<!-- Bottom sheet swipable (Métropole, Outre-mer et Fiches) -->
<ClientOnly>
<MobileSheet :resultCount="filtered.length" :pending="pending">
<MobileSheet v-if="mobileMapView !== 'fiches'" :resultCount="filtered.length" :pending="pending">
<!-- Barre recherche -->
<div class="px-3 pt-2 pb-2" style="border-bottom: 1px solid var(--nav-bg-alt);">
<label class="mobile-search-label" aria-label="Rechercher une organisation">
@@ -185,36 +227,36 @@
</button>
</label>
<!-- Filtres ÉCHELLE — chips style FONCTION -->
<!-- Filtres ÉCHELLE — Chip variant echelle (B3-M1 : alignées sur EchelleFilter.vue,
dupliquées ici car la sheet mobile ne passe pas par NavSidebar) -->
<div class="mt-2">
<span class="text-xs font-bold uppercase tracking-wide block mb-1" style="color: var(--nav-text-muted);">ÉCHELLE</span>
<div class="flex flex-wrap gap-1">
<span
<ChipGroup aria-label="Filtrer par échelle">
<Chip
v-for="opt in ECHELLES"
:key="opt"
class="cursor-pointer px-2 py-0.5 rounded-full text-xs transition-all"
:style="echelle.includes(opt)
? 'background: var(--nav-primary); color: var(--nav-text-on-primary); font-weight: 600;'
: 'background: var(--nav-bg-alt); color: var(--nav-text-muted);'"
@click="toggleEchelle(opt)"
>{{ opt }}</span>
</div>
:label="opt"
variant="echelle"
:active="echelle.includes(opt)"
@toggle="toggleEchelle(opt)"
/>
</ChipGroup>
</div>
<!-- Filtres FONCTION — chips flex-wrap -->
<!-- Filtres FONCTION — Chip variant fonction (B3-M1, idem) -->
<div class="mt-2">
<span class="text-xs font-bold uppercase tracking-wide block mb-1" style="color: var(--nav-text-muted);">FONCTION</span>
<div class="flex flex-wrap gap-1">
<span
<ChipGroup aria-label="Filtrer par fonction">
<Chip
v-for="fn in FONCTIONS"
:key="fn"
class="cursor-pointer px-2 py-0.5 rounded-full text-xs transition-all"
:style="fonctions.includes(fn)
? 'background: var(--nav-primary); color: var(--nav-text-on-primary); font-weight: 600;'
: 'background: var(--nav-bg-alt); color: var(--nav-text-muted);'"
@click="toggleFonction(fn)"
>{{ fn }}</span>
</div>
:label="fn"
variant="fonction"
:active="fonctions.includes(fn)"
:count="fonctions.includes(fn) ? (fonctions.indexOf(fn) + 1) : (fonctionCount[fn] ?? 0)"
@toggle="toggleFonction(fn)"
/>
</ChipGroup>
</div>
<button
@@ -272,6 +314,19 @@
</div>
</div>
</MobileSheet>
<!-- Bottom sheet — onglet « Fiches » mobile (B3-M3), contenu dédié -->
<MobileSheet v-else :resultCount="filtered.length" :pending="pending">
<FichesPanel
:fiches="allFichesMini"
:hashtags="[...FONCTIONS]"
:selected="fonctions"
:query="search"
intention="Toutes les structures d'entraide, y compris celles sans adresse sur la carte. Cochez une fonction pour filtrer."
@update:selected="onFonctions"
@open="(fiche) => onSelectOrgMobile(Number(fiche.id))"
/>
</MobileSheet>
</ClientOnly>
</div>
@@ -358,12 +413,14 @@ const territoireMode = ref<string>(
(route.query.mode as string) === 'outremer' ? 'outremer' : 'metropole'
)
const desktopMapView = ref<'metropole' | 'outremer'>('metropole')
type MapView = 'metropole' | 'outremer' | 'fiches'
const desktopMapView = ref<MapView>((route.query.vue as string) === 'fiches' ? 'fiches' : 'metropole')
const selectedId = ref<number | null>(null)
const chatbotOpen = ref(false)
const ficheModalOpen = ref(false)
const ficheModalId = ref<number | null>(null)
const mobileMapView = ref<'metropole' | 'outremer'>('metropole')
const mobileMapView = ref<MapView>((route.query.vue as string) === 'fiches' ? 'fiches' : 'metropole')
const missionOpen = ref(false)
onMounted(() => {
@@ -411,9 +468,20 @@ function syncUrl() {
if (fonctions.value.length) q.fonctions = fonctions.value.join(',')
if (territoire.value) q.territoire = territoire.value
if (territoireMode.value === 'outremer') q.mode = 'outremer'
if (desktopMapView.value === 'fiches' || mobileMapView.value === 'fiches') q.vue = 'fiches'
router.replace({ query: Object.keys(q).length ? q : undefined })
}
// Changement de vue (carte/fiches) — passe par syncUrl pour poser/retirer ?vue=fiches
function setDesktopView(v: MapView) {
desktopMapView.value = v
syncUrl()
}
function setMobileView(v: MapView) {
mobileMapView.value = v
syncUrl()
}
// Sauvegarde les filtres courants dans sessionStorage pour le bouton retour des fiches
function storeFiltersForBack() {
if (typeof window === 'undefined') return
@@ -541,6 +609,12 @@ const filtered = computed<Org[]>(() => {
return result
})
// Panneau « Toutes les fiches » (B3-M3) — mêmes fiches que `filtered`, sans
// filtrer par géolocalisation : c'est le gain principal de cette mission,
// les fiches sans latitude/longitude (invisibles sur la carte) y apparaissent.
const { toMini } = useFicheAdapter()
const allFichesMini = computed(() => filtered.value.map((org) => toMini(org)))
const DOM_TOM = ['Guadeloupe', 'Martinique', 'Guyane', 'La Réunion', 'Mayotte']
const DOM_TOM_LIST = DOM_TOM
+11 -9
View File
@@ -11,16 +11,18 @@ export default {
extend: {
colors: {
/* Palette NAV V2 — Sobre institutionnel */
/* Pointent vers les variables CSS --nav-* (alias des rôles @transformer/ui,
voir assets/css/main.css) pour rester cohérentes clair/sombre. (B3-M0) */
nav: {
bg: '#f8f6f1',
'bg-alt':'#eee9df',
surface: '#ffffff',
primary: 'rgba(26, 34, 56, 0.6)',
'primary-solid': '#1a2238',
accent: '#f5b342',
text: '#1a2238',
'text-muted': 'rgba(26, 34, 56, 0.55)',
'text-on-primary': '#f8f6f1',
bg: 'var(--nav-bg)',
'bg-alt':'var(--nav-bg-alt)',
surface: 'var(--nav-surface)',
primary: 'var(--nav-primary)',
'primary-solid': 'var(--nav-primary-solid)',
accent: 'var(--nav-accent)',
text: 'var(--nav-text)',
'text-muted': 'var(--nav-text-muted)',
'text-on-primary': 'var(--nav-text-on-primary)',
},
/* Héritage V1 — gardé pour composants existants */
sage: {