Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
58937a04a3 | ||
|
|
38818f77ae | ||
|
|
d9f934a8b2 | ||
|
|
baa602e5f8 | ||
|
|
6087b8dd31 | ||
|
|
13af3fa1c5 | ||
|
|
1838f4b02d | ||
|
|
5b504b3e04 | ||
|
|
b2b8c13fee |
@@ -0,0 +1,84 @@
|
||||
# Design system nav-carte (AEP)
|
||||
|
||||
Ce fichier existe pour que B6 à B9 n'aient pas à relire les récaps de B3 (`1 PROJETS/DEV/AEP/Cascade-Refonte/recaps/B3-*.md` dans le vault Dropbox) pour comprendre ce qui a été posé. Si tu es sur B6+ et que tu touches au visuel de nav-carte, lis ceci d'abord.
|
||||
|
||||
## D'où viennent les tokens
|
||||
|
||||
Les couleurs, espacements, rayons, ombres, etc. ne sont **jamais** des valeurs en dur dans les composants nav-carte. Ils viennent de deux fichiers, chargés dans cet ordre dans `nuxt.config.ts` :
|
||||
|
||||
1. `@transformer/ui/tokens.css` — package npm partagé (`git+https://git.trans-former.fr/jules/design-system.git`), le schéma canonique de tokens par **rôle** (`--bg`, `--text`, `--primary`, `--focus-ring`, `--space-*`, `--radius-*`, `--fs-*`, etc.). Jamais cloné en local, jamais modifié depuis ce repo.
|
||||
2. `@transformer/ui/theme/aep.css` — la palette AEP spécifique (« Sobre institutionnel », validée par Jules), qui surcharge les rôles du point 1 avec les vraies couleurs : bleu nuit `#1a2238`, accent safran `#f5b342`, fond crème `#f8f6f1`. C'est ce fichier qui rend `--primary`, `--accent`, `--bg` etc. concrets pour AEP.
|
||||
|
||||
`assets/css/main.css` définit ensuite des variables `--nav-*` (héritées de l'ancienne palette V1, avant l'arrivée du DS). Elles sont **alias** des rôles ci-dessus : `--nav-bg: var(--bg)`, `--nav-text: var(--text)`, etc. Elles existent uniquement parce que tout le code existant (avant B3) les consomme — ne pas les supprimer sans grep préalable, ne pas en créer de nouvelles : pour du code neuf, consommer directement les tokens `--bg`/`--text`/`--primary`/... du DS.
|
||||
|
||||
**Trois variables restent volontairement non-aliasées**, propres à nav-carte :
|
||||
|
||||
- `--nav-primary-raw` — triplet RGB (`26, 34, 56`) utilisé dans des `rgba(var(--nav-primary-raw), x)`. Le DS n'expose pas de triplet RGB séparé, seulement des couleurs finales.
|
||||
- `--nav-primary-solid` — bleu nuit plein (`#1a2238` clair, `#c8d2f0` sombre). Différent de `--primary` du DS, qui est le bleu nuit à 60 % d'opacité (utilisé pour les surfaces translucides). `--nav-primary-solid` sert au texte et aux éléments qui doivent rester lisibles en plein (logo, soulignement d'onglet actif).
|
||||
- `--nav-text-on-primary` — texte de contraste, gardé en valeur littérale (`#f8f6f1` clair / `#111520` sombre) plutôt qu'aliasé sur `--on-primary` du DS. **Raison** : cette variable sert de texte de contraste sur DEUX fonds différents dans le code existant — `--nav-primary` (translucide) ET `--nav-primary-solid` (opaque) — qui ont une légèreté **inversée** en mode sombre (le translucide rend sombre une fois mélangé au fond, le solid `#c8d2f0` est un lavande clair). Le rôle `--on-primary` du DS ne colle qu'au premier cas ; l'aliaser cassait le contraste à une dizaine d'endroits (logo « AEP », boutons, bandeaux) en sombre. Si tu ajoutes un nouvel usage de cette variable, vérifie d'abord sur QUEL fond (translucide ou solid) le texte doit être lisible.
|
||||
|
||||
Dark mode : classe `.dark` posée sur `<html>` (`document.documentElement.classList.add('dark')`, persisté dans `localStorage.aep_theme`). `theme/aep.css` définit ses valeurs sombres sous le sélecteur `:root.dark, :root[data-theme="dark"]` — les deux formes sont acceptées, mais nav-carte n'utilise que `.dark`. Pas de bascule automatique par `prefers-color-scheme` (décision explicite : le site n'en a jamais eu, ça changerait ce que voient les visiteurs par défaut).
|
||||
|
||||
## Grammaire des chips par dimension
|
||||
|
||||
Trois dimensions, trois traitements visuels — posés en B3-M1, pensés pour rester stables jusqu'à B4+ (nouvelles dimensions à ajouter, pas à réinventer) :
|
||||
|
||||
| Dimension | Variant | Rendu inactif | Rendu actif |
|
||||
|---|---|---|---|
|
||||
| Échelle (national/régional/local) | `echelle` | outline, fond transparent | fond `--primary` plein, texte `--on-primary` |
|
||||
| Fonction / hashtag | `fonction` (défaut) | fond `--bg-alt` | fond `--primary` plein, texte `--on-primary` |
|
||||
| Posture (préparée pour B4, pas encore consommée) | `posture` | outline safran (`--accent`) | fond `--accent` plein, texte `--on-accent` **sombre** — jamais blanc sur safran (voir règle de contraste plus bas) |
|
||||
|
||||
Composants : `components/Chip.vue` (une chip, `<button>` ou `<span>` selon `as`) et `components/ChipGroup.vue` (conteneur `role="group"`, `flex-wrap`, gap). Toujours les deux ensemble — jamais une chip nue hors groupe (accessibilité : le groupe porte l'`aria-label` qui dit ce qu'on filtre).
|
||||
|
||||
```vue
|
||||
<ChipGroup aria-label="Filtrer par fonction">
|
||||
<Chip
|
||||
v-for="fn in fonctions"
|
||||
:key="fn"
|
||||
:label="fn"
|
||||
variant="fonction"
|
||||
:active="selected.includes(fn)"
|
||||
@toggle="toggleFonction(fn)"
|
||||
/>
|
||||
</ChipGroup>
|
||||
```
|
||||
|
||||
Pour une chip non cliquable (affichage seul, ex. liste de résultats) : `as="span"` — pas de `type="button"`, pas d'`aria-pressed`, pas de handler `@toggle` nécessaire.
|
||||
|
||||
## FicheMiniCard + useFicheAdapter — brancher une nouvelle source
|
||||
|
||||
`composables/useFicheAdapter.ts` expose `toMini(source): MiniFiche`, où `MiniFiche = { id, nom, resume, chips, echelle?, geoloc, kind }`. Aujourd'hui il distingue `Org` (NocoDB, page `/`) et `StructureV2` (JSON statique `reseaux-bifurcation.json`, page `/agences`) via `'famille_principale' in source` (seul champ propre à `StructureV2`).
|
||||
|
||||
**Pour brancher une 3e source** (B6+, ex. un nouveau type de fiche) :
|
||||
1. Ajoute son type à l'union `Org | StructureV2 | TonNouveauType` dans la signature de `toMini`.
|
||||
2. Ajoute une garde de type (comme `isStructure`) basée sur un champ qui n'existe QUE dans ton nouveau type — ne réutilise pas `famille_principale` ou un champ ambigu.
|
||||
3. Choisis l'ordre de repli du résumé : `buildResume([champA, champB, ...])` prend le premier champ non vide, en garde la première phrase, tronque à 110 caractères sur un mot. Vérifie quels champs de ton type contiennent du texte descriptif avant de les ordonner.
|
||||
4. `chips` = 3 étiquettes maximum (fonctions, hashtags, ou l'équivalent pour ton type). `geoloc` = `latitude != null && longitude != null` si ton type a des coordonnées, sinon `true` (pas de fiche à masquer côté carte).
|
||||
|
||||
`components/FicheMiniCard.vue` consomme uniquement `MiniFiche` — il n'a jamais besoin de connaître `Org` ou `StructureV2`. C'est ce découplage qui permet d'ajouter une source sans toucher au composant d'affichage.
|
||||
|
||||
## FichesPanel — le panneau « toutes les fiches »
|
||||
|
||||
`components/FichesPanel.vue` : props `fiches: MiniFiche[]` (déjà filtrée par la page appelante — le panneau ne refiltre pas, ne crée pas de second champ de recherche), `hashtags: string[]` (options de filtre), `selected: string[]`, `query: string` (sert seulement à nuancer le message d'état vide), `intention: string`. Émet `update:selected` et `open(fiche)`.
|
||||
|
||||
**Le panneau ne possède pas son propre état de filtre** : ses chips pilotent la variable réactive de la page appelante via `update:selected` (ex. `fonctions` sur `/`, `selectedHashtags` sur `/agences`). C'est ce qui garantit qu'une chip cochée dans le panneau et la même chip dans la sidebar (si elle est visible) affichent toujours le même état — une seule source de vérité, jamais deux.
|
||||
|
||||
**Hashtags repliés** (B3-M4) : au-delà de 14 chips (`COLLAPSE_THRESHOLD`), seules les premières s'affichent + un chip-bouton « voir les N autres » qui déplie. Les chips déjà actives restent visibles même repliées (union entre les 14 premières et les actives hors de ce lot). État déplié non persistant — `ref` locale, reset à chaque montage. `/` (10 fonctions) ne dépasse jamais le seuil ; `/agences` (~60 hashtags) se replie par défaut.
|
||||
|
||||
**Accès** : `?vue=fiches` dans l'URL, en plus des paramètres de filtre existants. Sur `/`, `desktopMapView`/`mobileMapView` gagnent une valeur `'fiches'` à côté de `'metropole'`/`'outremer'` ; sur `/agences`, à côté de `'metropole'`/`'outremer'`/`'graphe'`. Recharger l'URL avec `?vue=fiches` doit rouvrir directement le panneau, filtres compris.
|
||||
|
||||
**Mobile** : le panneau ne se monte JAMAIS dans un `MobileSheet` (le composant sheet demi-hauteur pensé pour flotter au-dessus d'une carte) — il n'y a pas de carte derrière la vue fiches, donc pas de sheet à demi-hauteur : le panneau occupe toute la zone sous la barre d'onglets, avec son propre scroll interne. Piège vécu en B3 : monter le panneau dans un `MobileSheet` par réflexe (comme les autres onglets) laisse ~45 % de l'écran vide au-dessus, puisque la sheet démarre repliée à mi-hauteur et qu'il n'y a rien à voir en dessous.
|
||||
|
||||
## Intention d'abord
|
||||
|
||||
Principe de design validé par Jules pour B3 : un panneau (ou une carte) s'ouvre sur **une ligne d'intention** avant l'outillage (filtres, compteur, grille). La ligne d'intention dit en une phrase ce que montre l'écran et le premier geste possible (« Toutes les structures d'entraide, y compris celles sans adresse sur la carte. Cochez une fonction pour filtrer. »). Dans `FichesPanel`, l'ordre de l'en-tête est fixe : intention → compteur → chips → grille. Ne pas remonter le compteur ou les chips au-dessus de l'intention, même pour gagner de la place.
|
||||
|
||||
## Motif maillage (favicon)
|
||||
|
||||
Le favicon (`public/favicon.svg`, décliné en `favicon-32.png`, `favicon-16.png`, `apple-touch-icon.png`) dessine un petit graphe : un nœud central relié à cinq nœuds périphériques par des arêtes, sur fond bleu nuit avec des nœuds/arêtes safran. C'est un **motif à décliner**, pas une icône isolée — il préfigure les liens de graphe et les connexions entre fiches attendus en B4 (postures), B8 et B9 (structures liées, graphe de réseau déjà présent dans `FicheModalV2` via `structuresVoisines`). Si tu dessines un composant de graphe ou de connexions entre fiches dans ces batchs, reprends ce vocabulaire visuel (nœuds pleins, arêtes fines, un nœud central mis en avant) plutôt que d'en inventer un nouveau.
|
||||
|
||||
## Deux règles de contraste
|
||||
|
||||
1. **Texte sombre sur l'accent safran, jamais blanc.** `--accent`/`--on-accent` : le blanc sur `#f5b342` échoue le contrôle AA (décision DS du 20/07/2026). `--on-accent` est toujours une couleur sombre (`#1a2238` clair, `#111520` sombre). Vérifié au pixel sur la variante `posture` de `Chip.vue`.
|
||||
2. **`--nav-text-on-primary` reste une valeur littérale**, pas un alias — voir l'explication détaillée dans la section tokens ci-dessus. Si un nouveau composant a besoin de texte de contraste sur un fond bleu nuit, vérifie d'abord si ce fond est `--nav-primary` (translucide) ou `--nav-primary-solid` (opaque) avant de choisir la variable de texte : elles n'ont pas la même réponse en mode sombre.
|
||||
@@ -100,6 +100,13 @@
|
||||
|
||||
<!-- Actions droite -->
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<NuxtLink
|
||||
to="/manifeste"
|
||||
class="px-3 py-1.5 rounded-lg text-sm font-medium transition-all hover:opacity-80 hidden md:inline-flex items-center gap-1"
|
||||
:style="route.path === '/manifeste' ? 'color: var(--nav-primary-solid); font-weight: 700;' : 'color: var(--nav-text-muted);'"
|
||||
>
|
||||
Manifeste
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
to="/a-propos"
|
||||
class="px-3 py-1.5 rounded-lg text-sm font-medium transition-all hover:opacity-80 hidden md:inline-flex items-center gap-1"
|
||||
@@ -156,8 +163,8 @@
|
||||
</svg>
|
||||
</NuxtLink>
|
||||
|
||||
<!-- Hamburger mobile (lg:hidden) — toujours en dernier à droite -->
|
||||
<div class="lg:hidden relative">
|
||||
<!-- Menu déroulant — toutes largeurs (les onglets n'y apparaissent qu'en dessous de 1024px) ; porte « Soutenir » -->
|
||||
<div class="relative">
|
||||
<button
|
||||
@click="hamburgerOpen = !hamburgerOpen"
|
||||
class="p-2 rounded-lg transition-all hover:opacity-80"
|
||||
@@ -178,16 +185,16 @@
|
||||
style="background: var(--nav-surface); border: 1px solid var(--nav-bg-alt); z-index: 9999;"
|
||||
@click="hamburgerOpen = false"
|
||||
>
|
||||
<NuxtLink to="/outils" class="block px-4 py-2.5 text-sm font-medium transition-opacity hover:opacity-70" :style="route.path === '/outils' ? 'color: var(--nav-primary-solid); font-weight: 700;' : 'color: var(--nav-text);'">Outils</NuxtLink>
|
||||
<NuxtLink to="/" class="block px-4 py-2.5 text-sm font-medium transition-opacity hover:opacity-70" :style="route.path === '/' ? 'color: var(--nav-primary-solid); font-weight: 700;' : 'color: var(--nav-text);'">Écosystème Entraide Architecture</NuxtLink>
|
||||
<NuxtLink to="/agences" class="block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" :style="route.path === '/agences' ? 'color: var(--nav-primary-solid); font-weight: 700;' : 'color: var(--nav-text);'">Réseaux AEP</NuxtLink>
|
||||
<NuxtLink to="/trouver-du-taf" class="block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" :style="route.path === '/trouver-du-taf' ? 'color: var(--nav-primary-solid); font-weight: 700;' : 'color: var(--nav-text);'">Jobs</NuxtLink>
|
||||
<NuxtLink to="/rag" class="block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" style="color: var(--nav-text);">RAG</NuxtLink>
|
||||
<NuxtLink to="/codev" class="block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" :style="route.path.startsWith('/codev') ? 'color: var(--nav-primary-solid); font-weight: 700;' : 'color: var(--nav-text);'">Codev</NuxtLink>
|
||||
<div style="height: 1px; background: var(--nav-bg-alt); margin: 4px 0;"></div>
|
||||
<NuxtLink to="/outils" class="lg:hidden block px-4 py-2.5 text-sm font-medium transition-opacity hover:opacity-70" :style="route.path === '/outils' ? 'color: var(--nav-primary-solid); font-weight: 700;' : 'color: var(--nav-text);'">Outils</NuxtLink>
|
||||
<NuxtLink to="/" class="lg:hidden block px-4 py-2.5 text-sm font-medium transition-opacity hover:opacity-70" :style="route.path === '/' ? 'color: var(--nav-primary-solid); font-weight: 700;' : 'color: var(--nav-text);'">Écosystème Entraide</NuxtLink>
|
||||
<NuxtLink to="/agences" class="lg:hidden block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" :style="route.path === '/agences' ? 'color: var(--nav-primary-solid); font-weight: 700;' : 'color: var(--nav-text);'">Réseaux AEP</NuxtLink>
|
||||
<NuxtLink to="/trouver-du-taf" class="lg:hidden block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" :style="route.path === '/trouver-du-taf' ? 'color: var(--nav-primary-solid); font-weight: 700;' : 'color: var(--nav-text);'">Jobs</NuxtLink>
|
||||
<NuxtLink to="/media" class="lg:hidden block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" :style="route.path.startsWith('/media') ? 'color: var(--nav-primary-solid); font-weight: 700;' : 'color: var(--nav-text);'">recherche-média</NuxtLink>
|
||||
<NuxtLink to="/codev" class="lg:hidden block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" :style="route.path.startsWith('/codev') ? 'color: var(--nav-primary-solid); font-weight: 700;' : 'color: var(--nav-text);'">Codev</NuxtLink>
|
||||
<div class="lg:hidden" style="height: 1px; background: var(--nav-bg-alt); margin: 4px 0;"></div>
|
||||
<NuxtLink to="/manifeste" class="block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" :style="route.path === '/manifeste' ? 'color: var(--nav-primary-solid); font-weight: 700;' : 'color: var(--nav-text-muted);'">Manifeste</NuxtLink>
|
||||
<NuxtLink to="/a-propos" class="block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" style="color: var(--nav-text-muted);">À propos</NuxtLink>
|
||||
<a href="https://liberapay.com/trans-former.fr/donate" target="_blank" rel="noopener noreferrer" class="block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" style="color: var(--nav-text-muted);">Soutenir →</a>
|
||||
<a href="https://liberapay.com/trans-former.fr/donate" target="_blank" rel="noopener noreferrer" class="block mx-3 my-2 px-4 py-2 rounded-lg text-sm font-semibold text-center transition-opacity hover:opacity-80" style="background: var(--nav-accent); color: var(--nav-text);">Soutenir</a>
|
||||
<NuxtLink to="/signaler" class="block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" style="color: var(--nav-text-muted);">Signaler</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
@@ -199,9 +206,6 @@
|
||||
<NuxtPage />
|
||||
</div>
|
||||
|
||||
<!-- Bandeau bas — transparence IA + soutien + compteurs semaine -->
|
||||
<BandeauBas />
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,488 +0,0 @@
|
||||
<template>
|
||||
<!-- ═══════════════════════════════════════ BANDEAU BAS ═══════════════════ -->
|
||||
<!-- DESKTOP uniquement (≥1024px) — mobile a le FAB séparé -->
|
||||
<footer
|
||||
v-if="!isMobile"
|
||||
ref="bandeauEl"
|
||||
class="bandeau-bas shrink-0"
|
||||
:class="{ 'bandeau-collapsed': isCollapsed }"
|
||||
aria-label="Informations projet AEP"
|
||||
@mouseenter="onMouseEnter"
|
||||
@mouseleave="onMouseLeave"
|
||||
>
|
||||
<!-- Contenu plein -->
|
||||
<div class="bandeau-inner" :class="{ 'bandeau-inner--hidden': isCollapsed }">
|
||||
|
||||
<!-- ── GAUCHE : Transparence IA ──────────────────────────────────────── -->
|
||||
<div class="bandeau-col">
|
||||
<p class="bandeau-label">Transparence IA</p>
|
||||
<template v-if="stats">
|
||||
<p class="bandeau-value">
|
||||
Coût IA ce mois : <strong>{{ stats.cout_mois_eur.toFixed(2) }} €</strong>
|
||||
·
|
||||
Tokens : <strong>{{ stats.tokens_mois.toLocaleString('fr-FR') }}</strong>
|
||||
</p>
|
||||
<!-- Jauge -->
|
||||
<div class="jauge-track" aria-label="Budget IA consommé" role="progressbar" :aria-valuenow="jaugePct" aria-valuemin="0" aria-valuemax="100">
|
||||
<div class="jauge-fill" :style="{ width: jaugePct + '%' }" />
|
||||
</div>
|
||||
<p class="bandeau-sub">
|
||||
{{ stats.requetes_mois }} requête{{ stats.requetes_mois !== 1 ? 's' : '' }} ce mois
|
||||
</p>
|
||||
</template>
|
||||
<template v-else-if="loading">
|
||||
<p class="bandeau-sub">Chargement…</p>
|
||||
</template>
|
||||
<template v-else>
|
||||
<p class="bandeau-sub">Données indisponibles</p>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- ── MILIEU : CTA Soutien ──────────────────────────────────────────── -->
|
||||
<div class="bandeau-col bandeau-col--center">
|
||||
<div class="soutenir-wrap">
|
||||
<button
|
||||
class="btn-soutenir"
|
||||
type="button"
|
||||
@click="modalOpen = true"
|
||||
@mouseenter="tooltipVisible = true"
|
||||
@mouseleave="tooltipVisible = false"
|
||||
@focus="tooltipVisible = true"
|
||||
@blur="tooltipVisible = false"
|
||||
aria-label="Soutenir le projet AEP sur Liberapay"
|
||||
>
|
||||
Soutenir le projet
|
||||
</button>
|
||||
<!-- Tooltip au hover -->
|
||||
<div v-if="tooltipVisible" class="soutenir-tooltip" role="tooltip">
|
||||
1 € = 30 fiches mises en ligne
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── DROITE : Compteurs semaine ────────────────────────────────────── -->
|
||||
<div class="bandeau-col bandeau-col--right">
|
||||
<p class="bandeau-label">Cette semaine</p>
|
||||
<template v-if="stats">
|
||||
<p class="bandeau-value">
|
||||
{{ stats.fiches_semaine }} fiche{{ stats.fiches_semaine !== 1 ? 's' : '' }} ajoutée{{ stats.fiches_semaine !== 1 ? 's' : '' }}
|
||||
</p>
|
||||
<p class="bandeau-sub">
|
||||
{{ stats.requetes_chatbot_semaine }} requête{{ stats.requetes_chatbot_semaine !== 1 ? 's' : '' }} chatbot
|
||||
</p>
|
||||
</template>
|
||||
<template v-else>
|
||||
<p class="bandeau-sub">—</p>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Barre fine (état collapsed) -->
|
||||
<div class="bandeau-thin" :class="{ 'bandeau-thin--visible': isCollapsed }">
|
||||
<span class="bandeau-thin-label">AEP · Transparence IA</span>
|
||||
</div>
|
||||
|
||||
<!-- ── MODAL Liberapay ───────────────────────────────────────────────── -->
|
||||
<Teleport to="body">
|
||||
<Transition name="backdrop">
|
||||
<div
|
||||
v-if="modalOpen"
|
||||
class="modal-backdrop"
|
||||
@click="modalOpen = false"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Transition>
|
||||
<Transition name="modal">
|
||||
<div
|
||||
v-if="modalOpen"
|
||||
class="modal-box"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Soutenir AEP sur Liberapay"
|
||||
>
|
||||
<button
|
||||
class="modal-close"
|
||||
type="button"
|
||||
@click="modalOpen = false"
|
||||
aria-label="Fermer"
|
||||
>
|
||||
<svg width="16" height="16" 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>
|
||||
<h2 class="modal-title">Soutenir AEP</h2>
|
||||
<p class="modal-desc">
|
||||
AEP est un outil libre, sans publicité, financé par les dons.
|
||||
1 € finance environ 30 fiches mises en ligne.
|
||||
</p>
|
||||
<div class="modal-widget">
|
||||
<iframe
|
||||
src="https://liberapay.com/trans-former.fr/widgets/button.html"
|
||||
width="95"
|
||||
height="22"
|
||||
style="border: 0;"
|
||||
title="Faire un don sur Liberapay"
|
||||
/>
|
||||
</div>
|
||||
<a
|
||||
href="https://liberapay.com/trans-former.fr/donate"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="modal-link"
|
||||
>
|
||||
Faire un don sur Liberapay →
|
||||
</a>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
|
||||
</footer>
|
||||
|
||||
<!-- Mobile (< 1024px) : pas de FAB — Soutenir est dans le menu hamburger -->
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface Stats {
|
||||
cout_mois_eur: number
|
||||
budget_mois: number
|
||||
tokens_mois: number
|
||||
co2_kg: number
|
||||
requetes_mois: number
|
||||
fiches_semaine: number
|
||||
requetes_chatbot_semaine: number
|
||||
}
|
||||
|
||||
const stats = ref<Stats | null>(null)
|
||||
const loading = ref(true)
|
||||
const modalOpen = ref(false)
|
||||
const tooltipVisible = ref(false)
|
||||
|
||||
// Desktop — replié par défaut, déploie au hover, replie immédiatement à la sortie
|
||||
const bandeauEl = ref<HTMLElement | null>(null)
|
||||
const isCollapsed = ref(true) // replié par défaut
|
||||
|
||||
const REFRESH_MS = 5 * 60 * 1000 // 5 minutes
|
||||
|
||||
// Détection mobile côté client
|
||||
const isMobile = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
isMobile.value = window.innerWidth < 1024
|
||||
|
||||
const handleResize = () => {
|
||||
isMobile.value = window.innerWidth < 1024
|
||||
}
|
||||
window.addEventListener('resize', handleResize)
|
||||
onUnmounted(() => window.removeEventListener('resize', handleResize))
|
||||
|
||||
fetchStats()
|
||||
const interval = setInterval(fetchStats, REFRESH_MS)
|
||||
onUnmounted(() => clearInterval(interval))
|
||||
})
|
||||
|
||||
function onMouseEnter() {
|
||||
isCollapsed.value = false
|
||||
}
|
||||
|
||||
function onMouseLeave() {
|
||||
// Repli immédiat — pas de timer
|
||||
isCollapsed.value = true
|
||||
}
|
||||
|
||||
async function fetchStats() {
|
||||
try {
|
||||
const res = await $fetch<Stats>('/api/stats')
|
||||
stats.value = res
|
||||
} catch {
|
||||
stats.value = null
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const jaugePct = computed(() => {
|
||||
if (!stats.value) return 0
|
||||
return Math.min(100, Math.round((stats.value.cout_mois_eur / stats.value.budget_mois) * 100))
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* ── Bandeau bas ─────────────────────────────────────────────────────────── */
|
||||
.bandeau-bas {
|
||||
background: rgba(26, 34, 56, 0.7); /* opacité 70% */
|
||||
color: var(--nav-text-on-primary);
|
||||
font-family: var(--nav-font, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif);
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||
transition: min-height 0.25s ease;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bandeau-collapsed {
|
||||
min-height: 32px !important;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.bandeau-inner {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 10px 20px;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
min-height: 64px;
|
||||
transition: opacity 0.2s ease, max-height 0.3s ease;
|
||||
max-height: 200px;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.bandeau-inner--hidden {
|
||||
opacity: 0;
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* Barre fine (collapsed) */
|
||||
.bandeau-thin {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.bandeau-thin--visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.bandeau-thin-label {
|
||||
font-size: 0.65rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* ── Colonnes ────────────────────────────────────────────────────────────── */
|
||||
.bandeau-col {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.bandeau-col--center {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 auto;
|
||||
padding-top: 8px; /* décaler légèrement vers le bas pour mieux centrer dans la hauteur */
|
||||
}
|
||||
|
||||
.bandeau-col--right {
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
/* ── Typo ────────────────────────────────────────────────────────────────── */
|
||||
.bandeau-label {
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.07em;
|
||||
opacity: 0.65;
|
||||
margin: 0;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.bandeau-value {
|
||||
font-size: 0.775rem;
|
||||
font-weight: 500;
|
||||
color: var(--nav-text-on-primary);
|
||||
margin: 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.bandeau-value strong {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.bandeau-sub {
|
||||
font-size: 0.68rem;
|
||||
opacity: 0.7;
|
||||
margin: 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* ── Jauge budget ────────────────────────────────────────────────────────── */
|
||||
.jauge-track {
|
||||
width: 100%;
|
||||
max-width: 180px;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
overflow: hidden;
|
||||
margin: 3px 0;
|
||||
}
|
||||
|
||||
.jauge-fill {
|
||||
height: 100%;
|
||||
border-radius: 2px;
|
||||
background: var(--nav-accent);
|
||||
transition: width 0.6s ease;
|
||||
}
|
||||
|
||||
/* ── Bouton soutenir + tooltip ───────────────────────────────────────────── */
|
||||
.soutenir-wrap {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.btn-soutenir {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 7px 16px;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
background: var(--nav-accent);
|
||||
color: var(--nav-text);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: opacity 0.15s, transform 0.1s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btn-soutenir:hover { opacity: 0.88; transform: translateY(-1px); }
|
||||
.btn-soutenir:active { opacity: 1; transform: translateY(0); }
|
||||
|
||||
.soutenir-tooltip {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 8px);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: var(--nav-primary-solid, #1a2238);
|
||||
color: var(--nav-text-on-primary);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
padding: 5px 10px;
|
||||
border-radius: 6px;
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.soutenir-tooltip::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
border: 5px solid transparent;
|
||||
border-top-color: var(--nav-primary-solid, #1a2238);
|
||||
}
|
||||
|
||||
/* ── Modal ───────────────────────────────────────────────────────────────── */
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 2000;
|
||||
background: rgba(26, 34, 56, 0.55);
|
||||
}
|
||||
|
||||
.modal-box {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
z-index: 2001;
|
||||
background: var(--nav-surface, #ffffff);
|
||||
border-radius: 16px;
|
||||
padding: 28px 24px 24px;
|
||||
width: min(380px, 90vw);
|
||||
box-shadow: 0 8px 40px rgba(26, 34, 56, 0.22);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
position: absolute;
|
||||
top: 14px;
|
||||
right: 14px;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
background: var(--nav-bg-alt, #eee9df);
|
||||
color: var(--nav-text-muted, rgba(26,34,56,0.55));
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.modal-close:hover { background: var(--nav-bg, #f8f6f1); }
|
||||
|
||||
.modal-title {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 700;
|
||||
color: var(--nav-text, #1a2238);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.modal-desc {
|
||||
font-size: 0.85rem;
|
||||
color: var(--nav-text-muted, rgba(26,34,56,0.55));
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.modal-widget {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
.modal-link {
|
||||
display: block;
|
||||
text-align: center;
|
||||
font-size: 0.8rem;
|
||||
color: var(--nav-primary-solid, #1a2238);
|
||||
text-decoration: underline;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.modal-link:hover { opacity: 0.7; }
|
||||
|
||||
/* ── Transitions ─────────────────────────────────────────────────────────── */
|
||||
.backdrop-enter-active, .backdrop-leave-active { transition: opacity 0.2s ease; }
|
||||
.backdrop-enter-from, .backdrop-leave-to { opacity: 0; }
|
||||
|
||||
.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%, -48%); }
|
||||
|
||||
.sheet-enter-active, .sheet-leave-active { transition: transform 0.3s cubic-bezier(0.32, 0.72, 0, 1); }
|
||||
.sheet-enter-from, .sheet-leave-to { transform: translateY(100%); }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.btn-soutenir { transition: none; }
|
||||
.jauge-fill { transition: none; }
|
||||
.modal-enter-active, .modal-leave-active { transition: none; }
|
||||
.backdrop-enter-active, .backdrop-leave-active { transition: none; }
|
||||
.sheet-enter-active, .sheet-leave-active { transition: none; }
|
||||
.bandeau-bas { transition: none; }
|
||||
.bandeau-inner { transition: none; }
|
||||
}
|
||||
</style>
|
||||
@@ -54,7 +54,6 @@
|
||||
<div v-if="messages.length === 0" class="onboarding-bubble">
|
||||
<p>Explore les 120 structures de la carte par la conversation. Je peux t'aider à trouver des collectifs, agences ou réseaux selon ta situation, ta pratique ou tes inspirations du moment.</p>
|
||||
<p class="example">Exemple : "Je cherche des acteurs de la rénovation de maisons individuelles en France, plutôt en milieu rural, avec des approches biosourcées ou low-tech."</p>
|
||||
<p style="margin-top: 8px; font-size: 0.72rem; opacity: 0.6;">Propulsé par Mistral FR - serveur européen souverain, zéro rétention.</p>
|
||||
</div>
|
||||
|
||||
<!-- Messages -->
|
||||
|
||||
@@ -69,7 +69,6 @@
|
||||
<div ref="messagesContainer" class="flex-1 overflow-y-auto px-4 py-4 flex flex-col gap-3">
|
||||
<!-- Message onboarding (avant la première question) -->
|
||||
<div v-if="messages.length === 0" class="onboarding-bubble">
|
||||
<p>Ce chatbot fonctionne sur un serveur européen souverain (Mistral FR, zéro rétention), conçu sobre en énergie.</p>
|
||||
<p>Pour m'aider à te répondre efficacement, formule ta requête ainsi :</p>
|
||||
<ul>
|
||||
<li>• Besoin : [ce que tu cherches]</li>
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
<template>
|
||||
<section class="echelle-tete" :class="{ 'echelle-tete--compact': compact }" :aria-label="`Échelle ${e.id} : ${e.titre}`">
|
||||
<p class="echelle-tete__kicker">
|
||||
<span class="echelle-tete__badge" aria-hidden="true">{{ e.id }}</span>
|
||||
<span class="echelle-tete__titre">{{ e.titre }}<span v-if="e.sousTitre" class="echelle-tete__sous-titre">{{ ' ' + e.sousTitre }}</span></span>
|
||||
</p>
|
||||
<ul class="echelle-tete__mots">
|
||||
<li v-for="m in e.mots" :key="m">{{ m }}</li>
|
||||
</ul>
|
||||
<p class="echelle-tete__phrase">{{ e.phrase }}</p>
|
||||
<slot />
|
||||
<p v-if="contribuer" class="echelle-tete__contribuer">
|
||||
<span>{{ ECHELLES_CADRE_COURT }}</span>
|
||||
<NuxtLink :to="`/proposer?categorie=${e.categorieProposer}`" @click="$emit('navigate')">Proposer une ressource →</NuxtLink>
|
||||
</p>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ECHELLES, type EchelleId } from '~/utils/echelles'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
echelle: EchelleId
|
||||
/** Variante resserrée, pour une popup ou un en-tête centré. */
|
||||
compact?: boolean
|
||||
/** Affiche la ligne « on pose l'infrastructure, le collectif nourrit ». */
|
||||
contribuer?: boolean
|
||||
}>(), { compact: false, contribuer: true })
|
||||
|
||||
defineEmits<{ navigate: [] }>()
|
||||
|
||||
const e = computed(() => ECHELLES[props.echelle])
|
||||
const ECHELLES_CADRE_COURT = 'Chaque section est ouverte aux contributions.'
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.echelle-tete {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
margin: 0 0 var(--space-md);
|
||||
}
|
||||
|
||||
.echelle-tete__kicker {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--space-sm);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.echelle-tete__badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 2rem;
|
||||
padding: 0.1rem 0.5rem;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--accent);
|
||||
color: var(--on-accent);
|
||||
font-weight: var(--fw-bold);
|
||||
font-size: var(--fs-sm);
|
||||
letter-spacing: 0.04em;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.echelle-tete__titre {
|
||||
font-size: var(--fs-lg);
|
||||
font-weight: var(--fw-bold);
|
||||
color: var(--text);
|
||||
line-height: var(--lh-tight);
|
||||
}
|
||||
|
||||
.echelle-tete__sous-titre {
|
||||
font-weight: var(--fw-regular);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.echelle-tete__mots {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.15rem 0;
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--text);
|
||||
line-height: var(--lh-base);
|
||||
}
|
||||
.echelle-tete__mots li:not(:last-child)::after {
|
||||
content: '·';
|
||||
margin: 0 0.5rem;
|
||||
color: var(--accent);
|
||||
font-weight: var(--fw-bold);
|
||||
}
|
||||
|
||||
.echelle-tete__phrase {
|
||||
margin: 0;
|
||||
font-size: var(--fs-sm);
|
||||
font-style: italic;
|
||||
color: var(--text-muted);
|
||||
line-height: var(--lh-base);
|
||||
}
|
||||
|
||||
.echelle-tete__contribuer {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem 0.75rem;
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.echelle-tete__contribuer a {
|
||||
color: var(--link);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
.echelle-tete__contribuer a:hover { color: var(--link-hover); }
|
||||
|
||||
.echelle-tete--compact { text-align: left; }
|
||||
.echelle-tete--compact .echelle-tete__titre { font-size: var(--fs-base); }
|
||||
</style>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,168 @@
|
||||
<!--
|
||||
FichesPanel — B3-M3
|
||||
Panneau « toutes les fiches » : reçoit une liste déjà filtrée (recherche +
|
||||
filtres en cours côté page), l'affiche en grille de FicheMiniCard, et
|
||||
expose ses propres chips de filtrage branchées sur le MÊME état que la
|
||||
page appelante (pas un second état — voir vérification du brief).
|
||||
Ne crée pas de second champ de recherche : `query` sert uniquement à
|
||||
nuancer le message d'état vide.
|
||||
-->
|
||||
<template>
|
||||
<div class="fiches-panel">
|
||||
<div class="fiches-panel__head">
|
||||
<p class="fiches-panel__intention">{{ intention }}</p>
|
||||
|
||||
<div class="fiches-panel__count">
|
||||
{{ fiches.length }} fiche{{ fiches.length > 1 ? 's' : '' }}
|
||||
</div>
|
||||
|
||||
<ChipGroup v-if="hashtags.length" aria-label="Filtrer les fiches" class="fiches-panel__chips">
|
||||
<Chip
|
||||
v-for="tag in visibleHashtags"
|
||||
:key="tag"
|
||||
:label="tag"
|
||||
variant="fonction"
|
||||
:active="selected.includes(tag)"
|
||||
@toggle="toggle(tag)"
|
||||
/>
|
||||
<Chip
|
||||
v-if="isCollapsible"
|
||||
:label="expanded ? 'Réduire' : `Voir les ${hiddenCount} autres`"
|
||||
variant="echelle"
|
||||
:active="expanded"
|
||||
@toggle="expanded = !expanded"
|
||||
/>
|
||||
</ChipGroup>
|
||||
</div>
|
||||
|
||||
<div class="fiches-panel__body">
|
||||
<p v-if="fiches.length === 0" class="fiches-panel__empty">
|
||||
Aucun résultat<template v-if="query"> pour « {{ query }} »</template>.
|
||||
</p>
|
||||
|
||||
<div v-else class="fiches-panel__grid">
|
||||
<FicheMiniCard
|
||||
v-for="fiche in fiches"
|
||||
:key="`${fiche.kind}-${fiche.id}`"
|
||||
:fiche="fiche"
|
||||
@open="emit('open', fiche)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { MiniFiche } from '~/composables/useFicheAdapter'
|
||||
|
||||
const props = defineProps<{
|
||||
fiches: MiniFiche[]
|
||||
hashtags: string[]
|
||||
selected: string[]
|
||||
query: string
|
||||
intention: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:selected': [value: string[]]
|
||||
open: [fiche: MiniFiche]
|
||||
}>()
|
||||
|
||||
// Hashtags repliés (B3-M4) — au-delà de COLLAPSE_THRESHOLD chips, n'affiche
|
||||
// que les premières + un Chip-bouton « voir les N autres ». Les chips déjà
|
||||
// actives restent toujours visibles même repliées. `/` (10 fonctions) ne
|
||||
// dépasse jamais le seuil ; `/agences` (~60 hashtags) se replie. État
|
||||
// déplié non persistant (ref locale, reset à chaque montage du composant).
|
||||
const COLLAPSE_THRESHOLD = 14
|
||||
const expanded = ref(false)
|
||||
|
||||
const isCollapsible = computed(() => props.hashtags.length > COLLAPSE_THRESHOLD)
|
||||
|
||||
const visibleHashtags = computed(() => {
|
||||
if (expanded.value || !isCollapsible.value) return props.hashtags
|
||||
const head = props.hashtags.slice(0, COLLAPSE_THRESHOLD)
|
||||
const headSet = new Set(head)
|
||||
const activeOutsideHead = props.hashtags.filter(
|
||||
(tag) => props.selected.includes(tag) && !headSet.has(tag)
|
||||
)
|
||||
return [...head, ...activeOutsideHead]
|
||||
})
|
||||
|
||||
const hiddenCount = computed(() => props.hashtags.length - visibleHashtags.value.length)
|
||||
|
||||
function toggle(tag: string) {
|
||||
if (props.selected.includes(tag)) {
|
||||
emit('update:selected', props.selected.filter((t) => t !== tag))
|
||||
} else {
|
||||
emit('update:selected', [...props.selected, tag])
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fiches-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.fiches-panel__head {
|
||||
flex-shrink: 0;
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.fiches-panel__intention {
|
||||
margin: 0 0 var(--space-xs);
|
||||
font-size: var(--fs-sm);
|
||||
line-height: var(--lh-base);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.fiches-panel__count {
|
||||
margin: 0 0 var(--space-xs);
|
||||
font-size: var(--fs-xs);
|
||||
font-weight: var(--fw-semibold);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--text-subtle);
|
||||
}
|
||||
|
||||
.fiches-panel__chips {
|
||||
margin-top: var(--space-2xs);
|
||||
}
|
||||
|
||||
.fiches-panel__body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: var(--space-md);
|
||||
}
|
||||
|
||||
.fiches-panel__empty {
|
||||
margin: var(--space-xl) 0;
|
||||
text-align: center;
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.fiches-panel__grid {
|
||||
display: grid;
|
||||
grid-template-columns: 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>
|
||||
@@ -31,7 +31,7 @@
|
||||
<p style="font-size: 0.875rem; line-height: 1.6; opacity: 0.75; margin: 0 0 24px 0;">
|
||||
Elle ne prétend pas à l'exhaustivité. Elle est un geste politique :
|
||||
rendre visible ce qui se transforme, comment, par qui, où.
|
||||
5 familles et des hashtags vous permettent d'explorer.
|
||||
Six familles et des hashtags vous permettent d'explorer.
|
||||
</p>
|
||||
<button
|
||||
@click="dismiss"
|
||||
|
||||
@@ -263,10 +263,10 @@
|
||||
<Transition name="modal">
|
||||
<div v-if="ragInfoOpen" class="fixed z-[2001] left-1/2 flex flex-col"
|
||||
style="top:50%;transform:translate(-50%,-50%);width:min(580px,94vw);max-height:85vh;background:var(--nav-bg);border-radius:14px;box-shadow:0 16px 64px rgba(26,34,56,0.28);overflow:hidden;"
|
||||
role="dialog" aria-modal="true" aria-label="A propos du RAG FRACAS">
|
||||
role="dialog" aria-modal="true" aria-label="À propos du RAG FRACAS">
|
||||
<div class="flex items-center justify-between px-5 py-4 shrink-0"
|
||||
style="border-bottom:2px solid var(--nav-bg-alt);background:var(--nav-surface);">
|
||||
<h2 class="font-bold text-base" style="color:var(--nav-text);">FRACAS - Bibliotheque des pensees ecologiques</h2>
|
||||
<h2 class="font-bold text-base" style="color:var(--nav-text);">FRACAS - Bibliothèque des pensées écologiques</h2>
|
||||
<button @click="ragInfoOpen = false" class="ml-3 shrink-0 flex items-center justify-center w-8 h-8 rounded-full hover:opacity-70"
|
||||
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">
|
||||
@@ -275,8 +275,9 @@
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex-1 overflow-y-auto px-5 py-4" style="color:var(--nav-text);font-size:0.875rem;line-height:1.6;">
|
||||
<p class="mb-3">Une bibliotheque parlante politisee - des pensees ecologiques de gauche, organisees pour aider a creer une pensee complexe et nuancee, critiquer le recit dominant et soutenir des alternatives concretes et des projets collectifs.</p>
|
||||
<p class="mb-4" style="color:var(--nav-text-muted);font-size:0.8rem;">Projet open source, ouvert a toutes et a tous - <a href="https://bonpote.com/la-carte-des-pensees-ecologiques/" target="_blank" rel="noopener" style="text-decoration:underline;">article + carte FRACAS Bonpote V2</a>.</p>
|
||||
<EchelleTete echelle="XL" compact @navigate="ragInfoOpen = false" />
|
||||
<p class="mb-3">Une bibliothèque parlante des pensées écologiques, organisée pour aider à créer une pensée complexe et nuancée, à soutenir l'écriture de nouveaux récits, des alternatives concrètes et des projets collectifs.</p>
|
||||
<p class="mb-4" style="color:var(--nav-text-muted);font-size:0.8rem;">Projet open source, ouvert à toutes et à tous - <a href="https://bonpote.com/la-carte-des-pensees-ecologiques/" target="_blank" rel="noopener" style="text-decoration:underline;">article + carte FRACAS Bonpote V2</a>.</p>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="p-3 rounded-lg" style="background:var(--nav-bg-alt);">
|
||||
<p class="font-semibold mb-1" style="font-size:0.8rem;color:var(--nav-text-muted);text-transform:uppercase;letter-spacing:0.05em;">Ce qu'est un RAG</p>
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
|
||||
<div class="mission-body">
|
||||
<h2 id="mission-title" class="mission-title">{{ title }}</h2>
|
||||
<EchelleTete v-if="echelle" :echelle="echelle" compact @navigate="close" />
|
||||
<slot>
|
||||
<p class="mission-text">
|
||||
L'architecture est l'une des professions les plus complexes qui soit ; elle croise droit, technique, esthétique, économie, social, écologie — tout à la fois, tout simultanément, souvent sans filet. Paradoxalement, c'est aussi l'une des moins structurées sur le plan de l'entraide : peu de transmission horizontale, beaucoup d'isolement, une culture du chacun-pour-soi héritée d'une formation qui prépare à la compétition plus qu'à la coopération. On sort de l'école seul·e. On s'installe seul·e. On réinvente ce que d'autres ont déjà traversé.
|
||||
@@ -52,11 +53,15 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { EchelleId } from '~/utils/echelles'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
modelValue: boolean
|
||||
title?: string
|
||||
ctaLabel?: string
|
||||
storageKey?: string
|
||||
/** Tête d'échelle du deck (S / M / XL) affichée sous le titre. Absente sur /agences tant que l'arbitrage n° 1 n'est pas rendu. */
|
||||
echelle?: EchelleId
|
||||
}>(), {
|
||||
title: "L'écosystème d'entraide architecte",
|
||||
ctaLabel: 'Explorer la carte',
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
+12
-1
@@ -1,5 +1,17 @@
|
||||
export default defineNuxtConfig({
|
||||
modules: ['@nuxtjs/tailwindcss'],
|
||||
|
||||
app: {
|
||||
head: {
|
||||
link: [
|
||||
{ rel: 'icon', type: 'image/svg+xml', href: '/favicon.svg' },
|
||||
{ rel: 'icon', type: 'image/png', sizes: '32x32', href: '/favicon-32.png' },
|
||||
{ rel: 'icon', type: 'image/png', sizes: '16x16', href: '/favicon-16.png' },
|
||||
{ rel: 'apple-touch-icon', sizes: '180x180', href: '/apple-touch-icon.png' },
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
css: [
|
||||
'@transformer/ui/tokens.css',
|
||||
'@transformer/ui/theme/aep.css',
|
||||
@@ -38,7 +50,6 @@ export default defineNuxtConfig({
|
||||
ssr: true,
|
||||
|
||||
vite: {
|
||||
cacheDir: 'C:/Users/jules/AppData/Local/nav-carte-vite-cache',
|
||||
optimizeDeps: {
|
||||
include: ['leaflet', 'leaflet.markercluster', 'd3'],
|
||||
},
|
||||
|
||||
+1
-66
@@ -13,7 +13,7 @@
|
||||
<section class="section-mission">
|
||||
<h1>À propos d'AEP</h1>
|
||||
<p class="mission-text">
|
||||
AEP — Architecture d'Écologie Politique — est un commun vivant : une infrastructure d'entraide, de ressources documentées et de cartographies au service d'une profession en mutation. Ce site rassemble trois cartes (entraide, réseaux engagés, plateformes de mise en relation), un manifeste, une transparence radicale sur l'IA et le financement, et une gouvernance partagée.
|
||||
AEP — Architecture d'Écologie Politique — est un commun vivant : une infrastructure d'entraide, de ressources documentées et de cartographies au service d'une profession en mutation. Ce site rassemble les outils, l'écosystème d'entraide, les réseaux AEP, les plateformes de mise en relation, le codev, la bibliothèque des pensées écologiques et les projets, un manifeste, une transparence radicale sur l'IA et le financement, et une gouvernance partagée.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
@@ -27,14 +27,6 @@
|
||||
Ce site tourne sur un VPS - un serveur privé loué en Europe - construit entièrement avec des logiciels open source, quasi gratuits. Pas de services Google, pas d'Amazon, pas de Microsoft dans la chaîne. C'est un choix délibéré : contrôler son infrastructure, c'est contrôler ses données.
|
||||
</p>
|
||||
<div class="badges-row">
|
||||
<div class="badge">
|
||||
<span class="badge-icon" aria-hidden="true">
|
||||
<!-- Mistral logo placeholder -->
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 8v4l3 3"/></svg>
|
||||
</span>
|
||||
<span class="badge-label">IA souveraine</span>
|
||||
<span class="badge-detail">Mistral AI, Paris - conforme RGPD ; pas de collecte ni de profiling ; aucun transfert hors UE</span>
|
||||
</div>
|
||||
<div class="badge">
|
||||
<span class="badge-icon" aria-hidden="true">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>
|
||||
@@ -103,22 +95,8 @@
|
||||
L'IA est un outil. La question, c'est au service de quoi. Oui, elle est énergivore - et il faut l'assumer. Mais si c'est au service de l'émancipation, du collectif, de transformer nos infrastructures sociétales, alors on dit oui - en sachant que c'est une étape transitoire. L'infrastructure de l'IA elle-même peut évoluer : devenir plus locale, plus sobre, plus souveraine. C'est dans cette direction qu'on regarde.
|
||||
</p>
|
||||
|
||||
<div class="ia-grid">
|
||||
<div class="ia-card">
|
||||
<div class="ia-card-label">Enrichissement des fiches</div>
|
||||
<div class="ia-card-model">Mistral Nemo</div>
|
||||
<div class="ia-card-cost">~0,00003 €/fiche</div>
|
||||
</div>
|
||||
<div class="ia-card">
|
||||
<div class="ia-card-label">Chatbot de recherche</div>
|
||||
<div class="ia-card-model">Mistral Small</div>
|
||||
<div class="ia-card-cost">~0,003 €/requête</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul class="ia-details">
|
||||
<li>10 € / mois investis au départ par le collectif - vos dons constituent la réserve pour faire vivre le projet et financer ses recherches</li>
|
||||
<li>Coût visible en direct dans le bandeau bas de la carte</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
@@ -398,49 +376,6 @@ function copyApiUrl() {
|
||||
|
||||
/* ── IA grid ─────────────────────────────────────────────────────────────────── */
|
||||
|
||||
.ia-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.ia-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.ia-card {
|
||||
background: var(--nav-surface);
|
||||
border: 1px solid rgba(26, 34, 56, 0.1);
|
||||
border-radius: 10px;
|
||||
padding: 1rem 1.125rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.ia-card-label {
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--nav-text-muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ia-card-model {
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
color: var(--nav-text);
|
||||
}
|
||||
|
||||
.ia-card-cost {
|
||||
font-size: 0.825rem;
|
||||
color: var(--nav-text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.ia-details {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
|
||||
+103
-12
@@ -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 />
|
||||
@@ -113,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 -->
|
||||
@@ -195,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">
|
||||
@@ -273,8 +310,30 @@
|
||||
</ClientOnly>
|
||||
</div>
|
||||
|
||||
<!-- Bottom sheet swipable (masqué en vue graphique pour ne pas occulter le canvas) -->
|
||||
<ClientOnly v-if="mobileMapView !== 'graphe'">
|
||||
<!-- Zone « Fiches » mobile (B3-M3) — pleine hauteur sous la barre d'onglets,
|
||||
pas de carte dessous donc pas de sheet demi-hauteur (même logique que
|
||||
la vue graphe qui masque déjà le MobileSheet). Fix 2026-09-25 : la
|
||||
version précédente empilait un second MobileSheet (état half par
|
||||
défaut) au-dessus d'un fond neutre plein écran, laissant ~45% de
|
||||
hauteur vide au-dessus du panneau. -->
|
||||
<div
|
||||
v-show="mobileMapView === 'fiches'"
|
||||
class="absolute inset-0 flex flex-col overflow-hidden"
|
||||
style="background: var(--nav-bg-alt);"
|
||||
>
|
||||
<FichesPanel
|
||||
:fiches="allStructuresMini"
|
||||
:hashtags="allHashtags"
|
||||
:selected="selectedHashtags"
|
||||
:query="search"
|
||||
intention="Toutes les agences et réseaux du répertoire, filtrables par hashtag."
|
||||
@update:selected="selectedHashtags = $event"
|
||||
@open="(fiche) => onSelectStructureMobile(String(fiche.id))"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Bottom sheet swipable (masqué en vue graphique et en vue fiches) -->
|
||||
<ClientOnly v-if="mobileMapView !== 'graphe' && mobileMapView !== 'fiches'">
|
||||
<MobileSheet :resultCount="filtered.length" :pending="pending">
|
||||
<!-- Bandeau intention mobile -->
|
||||
<div class="px-3 py-2" style="background: var(--bifurc-banner-bg, #faf8f5); border-bottom: 1px solid var(--bifurc-banner-border, #e0d8cc);">
|
||||
@@ -425,6 +484,7 @@
|
||||
title="Réseaux AEP — l'architecture qui s'engage"
|
||||
ctaLabel="Explorer les 120 réseaux"
|
||||
storageKey="aep_reseaux_seen"
|
||||
echelle="M"
|
||||
>
|
||||
<p class="mission-text">
|
||||
Cette carte rassemble <strong>120 réseaux, collectifs et agences</strong> qui pratiquent une architecture engagée — écologique, politique, biorégionale. Ce ne sont pas seulement des agences « vertes » : ce sont celles et ceux qui assument des positions, refusent des projets, expérimentent des modèles de gouvernance, mettent leurs ressources et leurs savoirs en commun.
|
||||
@@ -455,15 +515,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')) {
|
||||
@@ -556,6 +642,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(() => [])
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
<div class="lock-header">
|
||||
<h1>Co-développement</h1>
|
||||
<p class="lock-subtitle">Entraide entre pairs</p>
|
||||
<EchelleTete echelle="M" compact :contribuer="false" class="lock-echelle" />
|
||||
<p class="lock-intro">Cet espace est un cercle. Pour entrer, il y a un mot.</p>
|
||||
</div>
|
||||
|
||||
@@ -104,6 +105,13 @@ async function submit() {
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
.lock-echelle {
|
||||
text-align: left;
|
||||
padding: var(--space-md);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-alt);
|
||||
}
|
||||
|
||||
.lock-intro {
|
||||
font-size: 0.9rem;
|
||||
color: var(--nav-text-muted, #6b7280);
|
||||
|
||||
@@ -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>
|
||||
+91
-10
@@ -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,8 +193,30 @@
|
||||
</ClientOnly>
|
||||
</div>
|
||||
|
||||
<!-- Bottom sheet swipable (Métropole et Outre-mer) -->
|
||||
<ClientOnly>
|
||||
<!-- Zone « Fiches » mobile (B3-M3) — pleine hauteur sous la barre d'onglets,
|
||||
pas de carte dessous donc pas de sheet demi-hauteur (même logique que
|
||||
la vue graphe qui masque aussi le MobileSheet). Fix 2026-09-25 : la
|
||||
version précédente empilait un second MobileSheet (état half par
|
||||
défaut) au-dessus d'un fond neutre plein écran, laissant ~45% de
|
||||
hauteur vide au-dessus du panneau. -->
|
||||
<div
|
||||
v-show="mobileMapView === 'fiches'"
|
||||
class="absolute inset-0 flex flex-col overflow-hidden"
|
||||
style="background: var(--nav-bg-alt);"
|
||||
>
|
||||
<FichesPanel
|
||||
:fiches="allFichesMini"
|
||||
:hashtags="[...FONCTIONS]"
|
||||
:selected="fonctions"
|
||||
:query="search"
|
||||
intention="Toutes les structures d'entraide, y compris celles sans adresse sur la carte. Cochez une fonction pour filtrer."
|
||||
@update:selected="onFonctions"
|
||||
@open="(fiche) => onSelectOrgMobile(Number(fiche.id))"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Bottom sheet swipable (Métropole et Outre-mer uniquement) -->
|
||||
<ClientOnly v-if="mobileMapView !== 'fiches'">
|
||||
<MobileSheet :resultCount="filtered.length" :pending="pending">
|
||||
<!-- Barre recherche -->
|
||||
<div class="px-3 pt-2 pb-2" style="border-bottom: 1px solid var(--nav-bg-alt);">
|
||||
@@ -330,6 +391,7 @@
|
||||
<MissionPopup
|
||||
:modelValue="missionOpen"
|
||||
@update:modelValue="missionOpen = $event"
|
||||
echelle="M"
|
||||
/>
|
||||
|
||||
</div>
|
||||
@@ -358,12 +420,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 +475,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 +616,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
|
||||
|
||||
|
||||
+2
-1
@@ -9,6 +9,7 @@
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="outils-header__title">Outils</h1>
|
||||
<EchelleTete echelle="S" :contribuer="false" />
|
||||
<p class="outils-header__intro">
|
||||
En tant qu'architecte, on jongle avec une multitude d'outils — simulation, dessin,
|
||||
calcul, recherche, partage. Les mutualiser, se conseiller dessus, savoir lequel
|
||||
@@ -326,7 +327,7 @@ const fmhyGuide = [
|
||||
{ nom: 'DeepSeek', url: 'https://chat.deepseek.com/', desc: 'modèle de pointe, usage illimité gratuit' },
|
||||
{ nom: 'Qwen Studio', url: 'https://chat.qwen.ai/', desc: 'chat IA (Qwen) illimité gratuit' },
|
||||
{ nom: 'Google AI Studio', url: 'https://aistudio.google.com/', desc: 'Gemini en accès généreux gratuit' },
|
||||
{ nom: 'Mistral Le Chat', url: 'https://chat.mistral.ai/', desc: 'LLM français, souverain, gratuit' },
|
||||
{ nom: 'Mistral Le Chat', url: 'https://chat.mistral.ai/', desc: 'LLM français, gratuit' },
|
||||
{ nom: 'LM Studio', url: 'https://lmstudio.ai/', desc: 'faire tourner des modèles en local, sans cloud' },
|
||||
{ nom: 'Jan', url: 'https://jan.ai/', desc: 'application desktop libre pour IA locale' },
|
||||
{ nom: 'Open WebUI', url: 'https://openwebui.com/', desc: 'interface web auto-hébergeable pour modèles locaux' }
|
||||
|
||||
+5
-2
@@ -49,10 +49,13 @@ const TABS = [
|
||||
{ id: 'reseau', label: 'Réseau AEP' },
|
||||
{ id: 'jobs', label: 'Jobs' },
|
||||
{ id: 'outils', label: 'Outils' },
|
||||
{ id: 'references', label: 'Références RAG' },
|
||||
{ id: 'references', label: 'Références (bibliothèque)' },
|
||||
] as const
|
||||
|
||||
const activeTab = ref<string>('ecosysteme')
|
||||
// ?categorie=outils (lien « Proposer une ressource » des têtes d'échelle) présélectionne l'onglet
|
||||
const route = useRoute()
|
||||
const categorie = String(route.query.categorie ?? '')
|
||||
const activeTab = ref<string>(TABS.some(t => t.id === categorie) ? categorie : 'ecosysteme')
|
||||
const success = ref(false)
|
||||
|
||||
function onSuccess() {
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
<div class="taff-header">
|
||||
<div class="taff-header-inner">
|
||||
<h1 class="taff-title">Trouver du taf en archi</h1>
|
||||
<EchelleTete echelle="S" :contribuer="false" class="taff-echelle-tete" />
|
||||
<p class="taff-subtitle">
|
||||
Annuaire critique des plateformes de mise en relation et d'appels d'offres,
|
||||
pour les <strong>architectes indépendants</strong> — 70 % de la profession et sa part la plus précaire économiquement.
|
||||
@@ -296,7 +297,7 @@
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<p class="taff-chat-footer-note">Propulsé par Mistral · 20 questions/jour</p>
|
||||
<p class="taff-chat-footer-note">20 questions/jour</p>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
@@ -649,6 +650,10 @@ const parsedDescription = computed(() => {
|
||||
|
||||
.taff-header { padding: 2.5rem 1.5rem 1.5rem; border-bottom: 1px solid var(--nav-bg-alt); text-align: center; }
|
||||
.taff-header-inner { max-width: 680px; margin: 0 auto; }
|
||||
/* En-tête Jobs centré : la tête d'échelle suit. */
|
||||
.taff-echelle-tete { text-align: center; margin-bottom: var(--space-md); }
|
||||
.taff-echelle-tete :deep(.echelle-tete__kicker),
|
||||
.taff-echelle-tete :deep(.echelle-tete__mots) { justify-content: center; }
|
||||
.taff-title { font-size: 1.875rem; font-weight: 800; color: var(--nav-text); margin-bottom: 0.5rem; letter-spacing: -0.02em; }
|
||||
.taff-subtitle { font-size: 0.9375rem; color: var(--nav-text-muted); line-height: 1.6; margin-bottom: 0.625rem; }
|
||||
.taff-cible { font-size: 0.875rem; color: var(--nav-text-muted); line-height: 1.55; margin-bottom: 1rem; font-style: italic; }
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 6.1 KiB |
@@ -7,7 +7,7 @@
|
||||
"children": [
|
||||
{ "name": "ChatGPT (OpenAI)", "url": "https://chat.openai.com/", "desc": "LLM généraliste, référence." },
|
||||
{ "name": "Claude (Anthropic)", "url": "https://claude.ai/", "desc": "Excellent pour rédaction longue et analyse de documents." },
|
||||
{ "name": "Mistral Le Chat", "url": "https://chat.mistral.ai/", "desc": "LLM français, souverain, gratuit." },
|
||||
{ "name": "Mistral Le Chat", "url": "https://chat.mistral.ai/", "desc": "LLM français, gratuit." },
|
||||
{ "name": "Perplexity", "url": "https://www.perplexity.ai/", "desc": "Moteur de recherche IA avec sources citées." },
|
||||
{ "name": "Hugging Face", "url": "https://huggingface.co/", "desc": "Hub de modèles open source. Indispensable." },
|
||||
{ "name": "LM Studio", "url": "https://lmstudio.ai/", "desc": "Faire tourner des LLM localement, sans cloud." }
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 527 B |
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,21 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" role="img" aria-label="AEP maillage">
|
||||
<rect width="32" height="32" rx="7" fill="#14142B"/>
|
||||
<g stroke="#E9A23B" stroke-width="1.4" stroke-linecap="round">
|
||||
<line x1="16" y1="6" x2="26.5" y2="13.5"/>
|
||||
<line x1="26.5" y1="13.5" x2="22.5" y2="26"/>
|
||||
<line x1="22.5" y1="26" x2="9.5" y2="26"/>
|
||||
<line x1="9.5" y1="26" x2="5.5" y2="13.5"/>
|
||||
<line x1="5.5" y1="13.5" x2="16" y2="6"/>
|
||||
<line x1="15" y1="16.5" x2="16" y2="6"/>
|
||||
<line x1="15" y1="16.5" x2="22.5" y2="26"/>
|
||||
<line x1="15" y1="16.5" x2="5.5" y2="13.5"/>
|
||||
</g>
|
||||
<g fill="#E9A23B">
|
||||
<circle cx="16" cy="6" r="2.4"/>
|
||||
<circle cx="26.5" cy="13.5" r="2.4"/>
|
||||
<circle cx="22.5" cy="26" r="2.4"/>
|
||||
<circle cx="9.5" cy="26" r="2.4"/>
|
||||
<circle cx="5.5" cy="13.5" r="2.4"/>
|
||||
</g>
|
||||
<circle cx="15" cy="16.5" r="2.9" fill="#F5C560"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 892 B |
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Les trois échelles du deck du 23/09/2026 (conférence AEP), slides 15 à 18.
|
||||
* Texte repris mot pour mot : c'est la tête de chaque onglet (directive Jules du 25/09, point 2).
|
||||
* Source unique : la future nav S / M / XL (arbitrage n° 1, table R) lira aussi ce fichier.
|
||||
*/
|
||||
export type EchelleId = 'S' | 'M' | 'XL'
|
||||
|
||||
export interface Echelle {
|
||||
id: EchelleId
|
||||
titre: string
|
||||
sousTitre?: string
|
||||
mots: string[]
|
||||
phrase: string
|
||||
/** Catégorie de /proposer présélectionnée par le lien de contribution. */
|
||||
categorieProposer: 'ecosysteme' | 'reseau' | 'jobs' | 'outils' | 'references'
|
||||
}
|
||||
|
||||
export const ECHELLES: Record<EchelleId, Echelle> = {
|
||||
S: {
|
||||
id: 'S',
|
||||
titre: 'Partir de soi',
|
||||
sousTitre: "pour s'émanciper",
|
||||
mots: [
|
||||
'quête de sens',
|
||||
'où habiter, avec qui',
|
||||
'gagner sa vie',
|
||||
'santé mentale',
|
||||
'mener ses projets',
|
||||
'naviguer avec soi',
|
||||
'les compétences psychosociales',
|
||||
],
|
||||
phrase: 'Des ressources, des méthodes, des pairs : matériel et mutualisé.',
|
||||
categorieProposer: 'outils',
|
||||
},
|
||||
M: {
|
||||
id: 'M',
|
||||
titre: 'La communauté',
|
||||
mots: [
|
||||
'entraide',
|
||||
'mutualisation',
|
||||
'appartenance',
|
||||
'être vu, soutenu',
|
||||
'traverser les creux',
|
||||
'accomplissement',
|
||||
'le codev',
|
||||
],
|
||||
phrase: "C'est le groupe qui remet un individu debout. L'affinité fait tenir, la confrontation fait avancer.",
|
||||
categorieProposer: 'ecosysteme',
|
||||
},
|
||||
XL: {
|
||||
id: 'XL',
|
||||
titre: 'Contribuer',
|
||||
mots: [
|
||||
'contribution',
|
||||
'affiliation, filiation',
|
||||
'identité',
|
||||
'sens',
|
||||
'de nouveaux récits',
|
||||
'un nouveau contrat social',
|
||||
'la médecine du corps social',
|
||||
],
|
||||
phrase: 'La bibliothèque, les récits, la filiation entre projets.',
|
||||
categorieProposer: 'references',
|
||||
},
|
||||
}
|
||||
|
||||
/** Slide 15, commune aux trois échelles. */
|
||||
export const ECHELLES_CADRE = "Trois échelles, imbriquées. Pas un escalier : ça circule dans les deux sens. Entrez par celle qui presse."
|
||||
Reference in New Issue
Block a user