Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bbf6b0475d | ||
|
|
bf40b40f96 | ||
|
|
e80b226ba2 | ||
|
|
90808551f0 | ||
|
|
f25a7d3884 | ||
|
|
d10586c432 | ||
|
|
83d4bd12fa | ||
|
|
5fabcdee8a | ||
|
|
a70c9e0b4f |
@@ -7,4 +7,3 @@ dist
|
|||||||
*.log
|
*.log
|
||||||
|
|
||||||
*.tmp.*
|
*.tmp.*
|
||||||
.firecrawl/
|
|
||||||
|
|||||||
@@ -1,84 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -11,6 +11,48 @@ Journal technique de la V2. Décisions, anomalies, points bloquants, TODOs.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 2026-04-29 — Cascade Onglet 1 : Pratiques régénératives (P1 → P5b)
|
||||||
|
|
||||||
|
**Commit deploy :** `e80b226` (feat/aep-pratiques-regeneratives, 10 commits depuis main)
|
||||||
|
**Exécutant :** Sonnet (agent autonome P1-P5b)
|
||||||
|
|
||||||
|
### Chantier P1 → P5b (résumé)
|
||||||
|
|
||||||
|
Création complète de l'onglet "Pratiques régénératives" sur `aep.trans-former.fr` :
|
||||||
|
|
||||||
|
- **P1** : scaffold types + API statique `GET /api/pratiques` (52 fiches JSON `public/data/pratiques-regeneratives.json`)
|
||||||
|
- **P2** : page `/pratiques-regeneratives` — carte Leaflet Europe + accordéon DOM-TOM, sidebar filtres (type, pays, matériaux), composants `PratiqueCard.vue` + `PratiqueModal.vue`
|
||||||
|
- **P3** : ajout onglet "Pratiques régé" dans le header nav desktop + hamburger mobile
|
||||||
|
- **P4** : page `/proposer-pratique` — formulaire contribution avec Zod, endpoint `POST /api/submit-pratique` avec rate limit, `public/data/pratiques-pending.json`
|
||||||
|
- **P5a** : build local validé (3.04 MB, APIs 200, 500 SSR = bug Windows/Node 24 préexistant non-bloquant)
|
||||||
|
- **P5b** : deploy prod + smoke test (3/3 endpoints 200, SSR title OK, JSON 52 fiches)
|
||||||
|
|
||||||
|
### Deploy
|
||||||
|
|
||||||
|
- Méthode : `tar .output/ | ssh vps-hetzner "cd /opt/aep && tar -xzf -"` + `systemctl restart aep`
|
||||||
|
- Env diff local vs VPS : VPS a des vars supplémentaires (MISTRAL, NOCODB worker, RESEND) — additionnel non-conflictuel, pas d'impact
|
||||||
|
- Note `deploy.sh` : le script a un BOM UTF-8 (ligne 1 `\xEF\xBB\xBF#!/bin/bash`) qui cause un exit 1 sur le `read -p` quand stdin est un pipe. Contournement : exécution manuelle des étapes. A corriger en V3.
|
||||||
|
|
||||||
|
### Smoke test prod (2026-04-29 01:38 UTC)
|
||||||
|
|
||||||
|
| Endpoint | HTTP | Note |
|
||||||
|
|---|---|---|
|
||||||
|
| GET /pratiques-regeneratives | 200 | SSR OK, titre trouvé (2 occurrences) |
|
||||||
|
| GET /proposer-pratique | 200 | SSR OK |
|
||||||
|
| GET /api/pratiques | 200 | JSON valid, 52 fiches |
|
||||||
|
|
||||||
|
### Ce qui reste à valider (Jules, E2E BrowserMCP)
|
||||||
|
|
||||||
|
- Markers Leaflet visibles + cliquables (Europe + DOM-TOM)
|
||||||
|
- Sidebar filtres fonctionnels (type, pays, matériaux)
|
||||||
|
- Modal fiche + bouton retour preservant filtres
|
||||||
|
- Formulaire `/proposer-pratique` : submit + message succès
|
||||||
|
- Comportement mobile 375×667 (sheet bas, swipe filtres, fiche pleine page)
|
||||||
|
|
||||||
|
Prompt E2E disponible : `aep-communaute-build/PROMPT-BROWSERMCP-E2E.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 2026-04-27 — Session V3 : Finition mobile + Blog Liberapay + 3 deploys
|
## 2026-04-27 — Session V3 : Finition mobile + Blog Liberapay + 3 deploys
|
||||||
|
|
||||||
**Commit :** `a02a555` — feat(mobile): accordéon outremer, hamburger nav, logo AEP, fiches cliquables, chatbot fullscreen
|
**Commit :** `a02a555` — feat(mobile): accordéon outremer, hamburger nav, logo AEP, fiches cliquables, chatbot fullscreen
|
||||||
|
|||||||
@@ -204,8 +204,6 @@ cout_eur = ((tokens_in × 0.02 + tokens_out × 0.04) / 1_000_000) × 0.93
|
|||||||
|
|
||||||
Le filtre NocoDB par date (`gte,YYYY-MM-DD`) n'est pas supporté en v0.301.5. Contournement : récupération de tous les records stats_usage (limit=1000) et filtre JavaScript par mois/année.
|
Le filtre NocoDB par date (`gte,YYYY-MM-DD`) n'est pas supporté en v0.301.5. Contournement : récupération de tous les records stats_usage (limit=1000) et filtre JavaScript par mois/année.
|
||||||
|
|
||||||
**Côté chatbot (AF5, 29/09)** : `server/utils/circuitBreaker.ts` n'appliquait pas ce contournement. Il envoyait `where=(timestamp,gte,…)&fields=cout_eur,timestamp` et recevait un 422 à chaque appel : fail-open silencieux, budget jamais vérifié depuis la V2. Il suit désormais le worker : **aucune colonne nommée dans la requête** (ni `where`, ni `fields`, ni `sort`), lecture paginée (`limit`/`offset` 1000), mois filtré en JS sur `timestamp`, sinon `CreatedAt`, sinon `created_at`. Chaque lecture écrit au journal `[circuitBreaker] budget lu : X € / 20 € (n lignes AAAA-MM sur N lues)` ; un échec écrit `budget NON vérifié`. Test : `node scripts/test-circuit-breaker.mjs` (faux NocoDB qui rejette en 422 toute colonne absente). Seule `/api/chatbot` vérifie le budget ; `-reseaux` et `-taff` ne le lisent ni ne l'écrivent (asymétrie pré-existante), et les appels servis par un tier gratuit s'inscrivent à 0 €.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 7. Infrastructure
|
## 7. Infrastructure
|
||||||
@@ -332,91 +330,3 @@ Pattern à généraliser :
|
|||||||
4. Normalize(tags) → taxonomie contrôlée
|
4. Normalize(tags) → taxonomie contrôlée
|
||||||
5. Log(usage) → stats_usage
|
5. Log(usage) → stats_usage
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 11. B5 (2026-09-27) — Formulaire assoupli + pipe adapté (fusion, pas réécriture)
|
|
||||||
|
|
||||||
Ce qui suit documente les écarts posés par le batch B5 (décisions de la
|
|
||||||
maîtrise d'œuvre du 27/09). Les sections 1 à 10 ci-dessus restent la
|
|
||||||
référence de fond (taxonomie, système prompt d'origine, circuit breaker) ;
|
|
||||||
cette section documente ce qui a changé dans `worker/enrich.js` et le pipe
|
|
||||||
de soumission (`server/api/submit/`).
|
|
||||||
|
|
||||||
### 11.1 Mapping soumission libre → colonnes NocoDB existantes (M1)
|
|
||||||
|
|
||||||
Le formulaire `/proposer` assoupli (`components/FormLibre.vue`,
|
|
||||||
`utils/submitLibre.ts`) écrit dans la table orgas existante, **aucune colonne
|
|
||||||
nouvelle** :
|
|
||||||
|
|
||||||
| Champ formulaire | Colonne NocoDB | Règle |
|
|
||||||
|---|---|---|
|
|
||||||
| 1er lien de la liste | `url` | `null` si aucun lien |
|
|
||||||
| texte « pourquoi » + tous les liens | `description_user` | `{texte}\n\nLiens :\n{lien1}\n{lien2}...` — et si aucun type suggéré, la ligne `Type : non précisé` est préfixée en tête |
|
|
||||||
| — | `nom` | hostname du 1er lien, ou 60 premiers caractères du texte si aucun lien, préfixé `[à qualifier] ` |
|
|
||||||
| chip type suggéré | `submission_type` | valeur choisie (`ecosysteme`/`reseau`/`job`/`outil`) ; si aucune chip → `ecosysteme` par défaut (voir ligne `Type : non précisé` ci-dessus) |
|
|
||||||
| chip « Références » | *(table `ressources_references`, pas orgas)* | `titre`/`auteur` placeholders, `description` = texte + liens, comme les autres |
|
|
||||||
| email (optionnel) | `submitted_by_email` | — |
|
|
||||||
|
|
||||||
**À faire au déploiement** : ajouter l'option `libre` à la liste de valeurs
|
|
||||||
acceptées par `submission_type` si l'on veut un jour distinguer une
|
|
||||||
soumission libre d'une soumission via un des 5 formulaires détaillés — non
|
|
||||||
fait ici pour ne pas tester un ALTER contre la prod sans l'avoir vérifié.
|
|
||||||
|
|
||||||
### 11.2 Worker — écarts (M2)
|
|
||||||
|
|
||||||
| Sujet | NAV V2 (§1-10 ci-dessus) | AEP B5 |
|
|
||||||
|---|---|---|
|
|
||||||
| LLM | Mistral Nemo, appel direct | **Bifrost** (`${BIFROST_URL}/v1/chat/completions`, header `x-bf-vk`), modèle `WORKER_MODEL` (défaut `groq/openai/gpt-oss-20b` depuis le 28/09, voir §11.3) |
|
|
||||||
| Scrape | crawl4ai (Python, `AsyncHTTPCrawlerStrategy`) | **fetch natif Node 22** — timeout 8 s, corps plafonné 500 Ko, extraction titre + meta description + `og:*` + texte visible tronqué à 4000 caractères, `User-Agent: AEP/2.0 contact@trans-former.fr` |
|
|
||||||
| Notification | Resend (email Jules) | **ntfy** (`POST https://ntfy.sh/$NTFY_TOPIC`) — le message ne contient JAMAIS l'email ni le texte libre du contributeur, seulement id NocoDB / nom suggéré / type / confiance |
|
|
||||||
| Seuil « 5 fiches pending » | Email si ≥ 5 en attente | **Retiré** (décision MOE : volume faible, une notif par fiche traitée suffit — à réactiver si le volume monte) |
|
|
||||||
| Sortie JSON du LLM | `description_enrichie, points_cles, tags_fonction, echelle, territoire, localisation_ville, confiance` | `{nom, description, type_suggere, ville, tags[], confiance}` — le worker écrit ensuite dans les colonnes existantes (`description_enrichie`, `tags_fonction`, `localisation_ville`) |
|
|
||||||
| Écriture de `nom` | jamais réécrit | réécrit **seulement** si le nom actuel commence par `[à qualifier]` (placeholder posé par le formulaire assoupli) |
|
|
||||||
| Écriture de `submission_type` | jamais réécrit | réécrit **seulement** si `description_user` commence par `Type : non précisé` (l'utilisateur n'a pas choisi de chip) et que le type suggéré par le LLM est valide |
|
|
||||||
| Prix des tokens | fixe (prix Mistral Nemo) | variables `WORKER_PRICE_IN_USD_PER_M` / `WORKER_PRICE_OUT_USD_PER_M`, défaut **0** (tier Groq du tier RAPIDE Bifrost, gratuit) |
|
|
||||||
| Lock | `/tmp/nav-worker.lock` | `/tmp/aep-worker.lock` (configurable `WORKER_LOCK_FILE`) |
|
|
||||||
| Chemin VPS | `/opt/nav-carte/worker/` | `/opt/aep-worker/` (voir `deploy/aep-worker/README.md`) |
|
|
||||||
| Timer | 5 min | 15 min (`deploy/aep-worker/aep-worker.timer`) |
|
|
||||||
|
|
||||||
Le mode `--dry-run` (`node enrich.js --dry-run`) lit
|
|
||||||
`worker/fixtures/dry-run-rows.json` au lieu de NocoDB, n'écrit rien, et par
|
|
||||||
défaut mock aussi le scrape et l'appel Bifrost (`DRY_RUN_LIVE=1` pour
|
|
||||||
forcer de vrais appels réseau sans jamais toucher NocoDB).
|
|
||||||
|
|
||||||
### 11.3 Modèles Groq retirés — bascule sur gpt-oss (2026-09-28)
|
|
||||||
|
|
||||||
Groq ne sert plus `llama-3.1-8b-instant` ni `llama-3.3-70b-versatile`
|
|
||||||
(404 `model_not_found` via Bifrost, constaté au test M3 du 28/09).
|
|
||||||
|
|
||||||
| Consommateur | Avant | Après | Où |
|
|
||||||
|---|---|---|---|
|
|
||||||
| Worker (`worker/enrich.js`) | `groq/llama-3.1-8b-instant` | `groq/openai/gpt-oss-20b` | prod : `WORKER_MODEL` dans `/opt/aep/.env` depuis le 28/09 ; défaut du code aligné |
|
|
||||||
| Chatbots, tier RAPIDE | `groq/llama-3.1-8b-instant` | `groq/openai/gpt-oss-20b` | `server/utils/bifrost.ts`, replis inchangés |
|
|
||||||
| Chatbots, tier APPROFONDI | `groq/llama-3.3-70b-versatile` | `groq/openai/gpt-oss-120b` | `server/utils/bifrost.ts`, replis inchangés |
|
|
||||||
| RAG Pensées (LightRAG) | `gemini-oai/models/gemini-2.5-flash-lite` | inchangé | `/opt/lightrag/.env`, hors de ce routage |
|
|
||||||
|
|
||||||
Entre le retrait et ce correctif, chaque appel chatbot partait sur le
|
|
||||||
premier repli Gemini (`stats_usage` depuis le 23/09 environ) : service
|
|
||||||
rendu, modèle primaire jamais servi.
|
|
||||||
|
|
||||||
**gpt-oss est un modèle à raisonnement.** Deux conséquences :
|
|
||||||
- la réponse porte `choices[0].message.reasoning` à côté de `content`. Les
|
|
||||||
trois routes (`chatbot`, `chatbot-reseaux`, `chatbot-taff`) et le worker ne
|
|
||||||
lisent que `content` (JSON), `usage` et `extra_fields` : le champ est
|
|
||||||
ignoré, rien n'en dépend. `chatbot-pensees` lit la réponse de LightRAG
|
|
||||||
(`response`, `references`), pas celle de Bifrost ;
|
|
||||||
- les tokens de raisonnement sont décomptés de `max_tokens`. Si le
|
|
||||||
fournisseur rend un JSON tronqué en HTTP 200, Bifrost ne bascule pas et
|
|
||||||
l'usager lit « Je n'ai pas pu analyser ta demande » (s'il le rejette en
|
|
||||||
400, le repli Gemini prend le relais : dégradé, pas cassé). Les routes chatbot ajoutent donc
|
|
||||||
`REASONING_MARGIN_TOKENS` (800) à leur budget de contenu (600 ou 700), et
|
|
||||||
`bifrostContent()` écrit un avertissement au journal quand
|
|
||||||
`finish_reason` vaut `length`. Le worker garde `max_tokens: 800` (sortie
|
|
||||||
courte, M3 passé en 1,6 s) ; à relever si `tokens_out` approche 800 dans
|
|
||||||
`stats_usage`.
|
|
||||||
|
|
||||||
**Non testé depuis Windows** (Bifrost écoute sur le loopback du VPS). Test
|
|
||||||
réel au checkpoint : un appel par route, relever `extra_fields` (modèle
|
|
||||||
réellement servi), `usage.completion_tokens`, `finish_reason`, et le
|
|
||||||
modèle inscrit dans `stats_usage` pour `/api/chatbot`.
|
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# INDEX — Cascade Onglet 1 : Pratiques régénératives
|
||||||
|
|
||||||
|
> Branche : `feat/aep-pratiques-regeneratives`
|
||||||
|
> Démarré : 2026-04-28
|
||||||
|
> Dernière mise à jour : 2026-04-29
|
||||||
|
|
||||||
|
## Statut global
|
||||||
|
|
||||||
|
```
|
||||||
|
P1 Types + API statique [x] done — commit a70c9e0
|
||||||
|
P2 Page /pratiques-regeneratives [x] done — commit a70c9e0
|
||||||
|
P3 Onglet nav + hamburger [x] done — commit 5fabcde
|
||||||
|
P4 /proposer-pratique + POST API [x] done — commits 83d4bd1 d10586c f25a7d3
|
||||||
|
P5a Build local + smoke test [x] done — commit e80b226 (recap P5a-RECAP.md)
|
||||||
|
P5b Deploy prod + smoke test prod [x] done — deploy 2026-04-29 01:38 UTC
|
||||||
|
JOURNAL-V2.md entrée 2026-04-29 [x] done
|
||||||
|
PROMPT-BROWSERMCP-E2E.md créé [x] done
|
||||||
|
Branche pushée sur Gitea [x] done — feat/aep-pratiques-regeneratives
|
||||||
|
Mise en ligne validée (E2E Jules) [ ] en attente — Jules sur autre PC
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fichiers produits
|
||||||
|
|
||||||
|
| Fichier | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `aep-communaute-build/P4-RECAP.md` | Récap chantier P4 form + API |
|
||||||
|
| `aep-communaute-build/P5a-RECAP.md` | Récap build local Windows |
|
||||||
|
| `aep-communaute-build/PROMPT-BROWSERMCP-E2E.md` | Prompt E2E pour Jules (autre PC) |
|
||||||
|
| `aep-communaute-build/INDEX.md` | Ce fichier |
|
||||||
|
|
||||||
|
## Smoke test prod (2026-04-29 01:38 UTC)
|
||||||
|
|
||||||
|
| Endpoint | HTTP | Detail |
|
||||||
|
|----------|------|--------|
|
||||||
|
| GET /pratiques-regeneratives | 200 | SSR OK, titre trouve (2 occurrences) |
|
||||||
|
| GET /proposer-pratique | 200 | SSR OK |
|
||||||
|
| GET /api/pratiques | 200 | JSON valid, 52 fiches |
|
||||||
|
|
||||||
|
## Action Jules
|
||||||
|
|
||||||
|
1. Copier `PROMPT-BROWSERMCP-E2E.md` sur le PC avec BrowserMCP + Brave
|
||||||
|
2. Lancer une session Claude Code, coller le prompt
|
||||||
|
3. Reporter les resultats dans `aep-communaute-build/E2E-RESULTS.md`
|
||||||
|
4. Cocher "Mise en ligne validee (E2E Jules)" ci-dessus
|
||||||
|
5. Merger `feat/aep-pratiques-regeneratives` dans `main`
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
# P4 — Form contribution "Proposer une pratique" — Récap
|
||||||
|
|
||||||
|
**Date :** 2026-04-28
|
||||||
|
**Branche :** feat/aep-pratiques-regeneratives
|
||||||
|
**Commits :** 3 atomiques (83d4bd1, d10586c, f25a7d3)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fichiers créés / modifiés
|
||||||
|
|
||||||
|
| Fichier | Statut | Description |
|
||||||
|
|---------|--------|-------------|
|
||||||
|
| `pages/proposer-pratique.vue` | créé (833 lignes) | Formulaire complet clone adapté de contribuer.vue |
|
||||||
|
| `server/api/submit-pratique.post.ts` | créé (117 lignes) | Endpoint POST avec Zod + rate limit + append pending |
|
||||||
|
| `public/data/pratiques-pending.json` | créé (`[]`) | File de modération V1, nettoyée après tests |
|
||||||
|
| `components/PratiqueSidebar.vue` | modifié | CTA "+ Proposer une pratique" en bas de sidebar |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Formulaire — champs implémentés
|
||||||
|
|
||||||
|
- **Nom** (obligatoire, 3-150c)
|
||||||
|
- **URL** (optionnel)
|
||||||
|
- **Description** (obligatoire, 50-500c + compteur live)
|
||||||
|
- **Critères régénératifs** (checkboxes 8 critères, min 3 / max 8, désactivation au-delà)
|
||||||
|
- **Type d'entité** (radio pill : 9 options depuis TYPES_ENTITE)
|
||||||
|
- **Pays** (dropdown groupé Europe 16 codes / DOM-TOM 11 codes / Autre texte libre conditionnel)
|
||||||
|
- **Ville** (optionnel)
|
||||||
|
- **Tags** (optionnel, input virgule-séparé → chips preview, max 6 × 30c)
|
||||||
|
- **Email** (optionnel)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Endpoint serveur
|
||||||
|
|
||||||
|
- Validation Zod miroir du client (schéma identique)
|
||||||
|
- Rate limit JSON : 3 soumissions / IP hashée SHA-256 / jour (action `submit-pratique`)
|
||||||
|
- Lecture / écriture `public/data/pratiques-pending.json` (init `[]` si absent)
|
||||||
|
- Entrée : champs validés + `id: timestamp` + `submitted_at: ISO` + `moderation_status: 'pending'`
|
||||||
|
- Retour : `{ ok: true, trackingId: timestamp }`
|
||||||
|
- Commentaire modération V2 en haut du fichier
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Résultats tests
|
||||||
|
|
||||||
|
| Test | Résultat |
|
||||||
|
|------|----------|
|
||||||
|
| GET /proposer-pratique | 200 |
|
||||||
|
| POST valide | 200 + entrée pending.json |
|
||||||
|
| POST invalide (nom 2c, desc trop courte, criteres < 3) | 422 + fieldErrors structurés |
|
||||||
|
| Rate limit : 3ème soumission depuis même IP | 429 |
|
||||||
|
| pending.json après nettoyage | `[]` vide |
|
||||||
|
|
||||||
|
Note : la 3ème soumission (pas la 4ème) a déclenché le 429 car le test valide
|
||||||
|
précédent comptait comme 1ère soumission — comportement correct, limite = 3/jour.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Navigation
|
||||||
|
|
||||||
|
- CTA "Proposer une pratique" dans `PratiqueSidebar.vue` (section bas sidebar, style `sidebar-cta-link`)
|
||||||
|
- Bouton retour dans `proposer-pratique.vue` → `/pratiques-regeneratives`
|
||||||
|
- Bouton Annuler dans le formulaire → `/pratiques-regeneratives`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes implémentation
|
||||||
|
|
||||||
|
- `types/pratique.ts` non modifié — `CRITERES`, `TYPES_ENTITE`, `TYPES_ENTITE_LABELS`, `EUROPE_CODES`, `OUTREMER_CODES`, `PAYS_LABELS` importés tels quels
|
||||||
|
- Style réutilise 100% les classes CSS et variables CSS var(--nav-*) existantes
|
||||||
|
- Accentuation dans pending.json depuis curl Windows = artefact d'encodage terminal uniquement — depuis navigateur, l'encodage UTF-8 est correct
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prêt pour P5
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# P5a — RECAP Build local + smoke test
|
||||||
|
> Date : 2026-04-28 | Branche : feat/aep-pratiques-regeneratives
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
- Statut : OK
|
||||||
|
- Bundle total : 3.04 MB (737 kB gzip)
|
||||||
|
- Warnings : 1 (DEP0155 — trailing slash dans @vue/shared/package.json, Node deprecation inoffensif, non-bloquant)
|
||||||
|
- Errors : 0
|
||||||
|
- Durée : client 5.38s + server 3.27s + Nitro OK
|
||||||
|
|
||||||
|
## Smoke test local (node-server Windows)
|
||||||
|
|
||||||
|
| Endpoint | HTTP | Note |
|
||||||
|
|---|---|---|
|
||||||
|
| GET /pratiques-regeneratives | 500 | Voir note ci-dessous |
|
||||||
|
| GET /proposer-pratique | 500 | Voir note ci-dessous |
|
||||||
|
| GET /api/pratiques | 200 | Retourne la liste JSON (N entries visible) |
|
||||||
|
| POST /api/submit-pratique | 429 | Rate limit local (comportement attendu) |
|
||||||
|
|
||||||
|
**Note sur les 500 SSR — BUG WINDOWS/NODE 24, non-bloquant pour le deploy VPS :**
|
||||||
|
|
||||||
|
Erreur : `Only URLs with a scheme in: file, data, and node are supported by the default ESM loader. On Windows, absolute paths must be valid file:// URLs. Received protocol 'c:'`
|
||||||
|
|
||||||
|
Diagnostic : Nitro en mode `node-server` fait un `import()` dynamique avec chemin absolu Windows (`C:\...`) au lieu de `file:///C:/...`. Ce bug est systématique en local sur toutes les pages HTML (y compris `/`, `/a-propos`, etc.) — identique sur `main` avant cette branche. Il n'existe pas sur VPS Linux. Les APIs JSON ne sont pas affectées.
|
||||||
|
|
||||||
|
Conclusion : ce bug ne doit PAS bloquer le deploy P5b. Il est préexistant et propre à l'environnement Windows local.
|
||||||
|
|
||||||
|
## Console errors
|
||||||
|
|
||||||
|
- 1 warning DEP0155 (non-bloquant)
|
||||||
|
- 0 erreur critique
|
||||||
|
|
||||||
|
## Nettoyage
|
||||||
|
|
||||||
|
- pending.json : `[]` — propre (POST retourné 429, aucune entrée ajoutée)
|
||||||
|
- Processus preview/dev : stoppés
|
||||||
|
- git status : working tree propre (1 fichier non-tracké préexistant : `public/data/pratiques-regeneratives.json`)
|
||||||
|
|
||||||
|
## Branche
|
||||||
|
|
||||||
|
feat/aep-pratiques-regeneratives — 9 commits depuis main
|
||||||
|
|
||||||
|
```
|
||||||
|
9080855 docs(p4): recap P4 form proposer-pratique
|
||||||
|
f25a7d3 feat(pratiques): pending.json init + CTA sidebar proposer une pratique
|
||||||
|
d10586c feat(pratiques): page /proposer-pratique — formulaire contribution Pratique
|
||||||
|
83d4bd1 feat(pratiques): endpoint POST /api/submit-pratique avec Zod + rate limit
|
||||||
|
5fabcde feat(nav): ajout onglet Pratiques régé + hamburger + overflow
|
||||||
|
a70c9e0 feat(pratiques): types, API statique, composants filtres + cartes Europe/outremer
|
||||||
|
5eda4bd chore: supprimer fichiers tmp editeur parasites
|
||||||
|
21c44d8 feat(aep): carte AEP — push Gitea 2026-04-28
|
||||||
|
```
|
||||||
|
|
||||||
|
## Verdict
|
||||||
|
|
||||||
|
Build OK. Les 500 locaux SSR sont un artefact Windows/Node 24 non-reproductible sur VPS Linux. APIs fonctionnelles. Pending propre.
|
||||||
|
|
||||||
|
**Checkpoint Jules : OK pour deploy P5b ?**
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
# P5b — Deploy prod + smoke test — Récap
|
||||||
|
|
||||||
|
> Date : 2026-04-29 | Branche : feat/aep-pratiques-regeneratives
|
||||||
|
|
||||||
|
## Pre-deploy
|
||||||
|
|
||||||
|
- Commit avant deploy : `90808551f003fe3e8e1cd227b433594b5e6f087a`
|
||||||
|
- Branche : `feat/aep-pratiques-regeneratives`
|
||||||
|
- Fichiers non-trackés : P5a-RECAP.md + pratiques-regeneratives.json (stagés + committés avant deploy)
|
||||||
|
- `.output/public/data/pratiques-regeneratives.json` : present dans le build (Nuxt le copie automatiquement)
|
||||||
|
- `.output/server/chunks/routes/api/pratiques.get.mjs` : present
|
||||||
|
- `.output/server/chunks/routes/api/submit-pratique.post.mjs` : present
|
||||||
|
|
||||||
|
## Deploy
|
||||||
|
|
||||||
|
- Méthode : `tar .output/ | ssh vps-hetzner "cd /opt/aep && tar -xzf -"` + `systemctl restart aep`
|
||||||
|
- Raison du contournement deploy.sh : BOM UTF-8 en ligne 1 (#!/bin/bash) cause un exit 1 au `read -p` quand stdin est un pipe. Les étapes ont été exécutées manuellement.
|
||||||
|
- Env diff local vs VPS : VPS a des vars supplémentaires (MISTRAL, NOCODB worker, RESEND) — additionnel non-conflictuel, deploy pas impacté.
|
||||||
|
- Durée upload : < 5s
|
||||||
|
- Service aep : active (systemctl is-active = "active")
|
||||||
|
|
||||||
|
## Output deploy (résumé)
|
||||||
|
|
||||||
|
```
|
||||||
|
[2026-04-29 01:37:08] Upload .output/ vers vps-hetzner:/opt/aep...
|
||||||
|
[2026-04-29 01:37:08] Upload termine.
|
||||||
|
[2026-04-29 01:37:51] Redemarrage du service aep...
|
||||||
|
active
|
||||||
|
[2026-04-29 01:37:51] Service aep statut verifie.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Smoke test prod (2026-04-29 01:38 UTC)
|
||||||
|
|
||||||
|
| Endpoint | HTTP | Note |
|
||||||
|
|---|---|---|
|
||||||
|
| GET /pratiques-regeneratives | 200 | SSR OK |
|
||||||
|
| GET /proposer-pratique | 200 | SSR OK |
|
||||||
|
| GET /api/pratiques | 200 | JSON valid |
|
||||||
|
|
||||||
|
**Garde-fous additionnels :**
|
||||||
|
- SSR title check : `curl .../pratiques-regeneratives | grep -c "Pratiques"` → 2 occurrences trouvées
|
||||||
|
- JSON count : `node -e "..."` → 52 fiches (attendu : 52)
|
||||||
|
|
||||||
|
## Commits produits (P5b)
|
||||||
|
|
||||||
|
```
|
||||||
|
bf40b40 docs(p5b): journal deploy + INDEX + prompt BrowserMCP E2E
|
||||||
|
e80b226 docs(p5a): recap build local + add pratiques-regeneratives.json data
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notes deploy.sh — TODO V3
|
||||||
|
|
||||||
|
deploy.sh a deux problèmes identifiés :
|
||||||
|
1. BOM UTF-8 en ligne 1 (`\xEF\xBB\xBF#!/bin/bash`) — cause exit 1 quand stdin est un pipe
|
||||||
|
2. Le script était documenté comme "contournement tar + ssh" dans JOURNAL-V2 V2 (Session S3b) — cohérent ici
|
||||||
|
|
||||||
|
A corriger : supprimer le BOM (`sed -i '1s/^\xEF\xBB\xBF//' deploy.sh`) + ajouter `CONFIRM=y` par défaut ou flag `--force-env`.
|
||||||
|
|
||||||
|
## Statut final
|
||||||
|
|
||||||
|
Deploy OK. Smoke test 3/3. Branche pushée. Jules merge main apres E2E.
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
# PROMPT BrowserMCP — E2E Onglet Pratiques régénératives
|
||||||
|
> Cascade onglet 1 — à coller dans une session Claude Code avec BrowserMCP attaché à Brave.
|
||||||
|
> Date de création : 2026-04-29
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Mission
|
||||||
|
|
||||||
|
Tester en E2E les deux nouvelles pages de `aep.trans-former.fr` liées à l'onglet "Pratiques régénératives" : la carte principale `/pratiques-regeneratives` et le formulaire de contribution `/proposer-pratique`. Couvrir desktop ET mobile. Reporter tous les bugs trouvés (console JS, visuels, fonctionnels).
|
||||||
|
|
||||||
|
**URL de base :** `https://aep.trans-former.fr`
|
||||||
|
**Navigateur :** Brave avec extension BrowserMCP
|
||||||
|
**Mode :** Full auto, rapport final structuré
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Setup initial
|
||||||
|
|
||||||
|
1. Ouvre BrowserMCP et navigue vers `https://aep.trans-former.fr/pratiques-regeneratives`
|
||||||
|
2. Attends que la page soit fully loaded (pas de spinner Leaflet)
|
||||||
|
3. Prends un screenshot de référence `desktop-initial.png`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scenario 1 — Carte desktop (chargement + structure)
|
||||||
|
|
||||||
|
**Objectif :** vérifier que la carte s'affiche correctement avec markers et sidebar.
|
||||||
|
|
||||||
|
Étapes :
|
||||||
|
1. `navigate` vers `https://aep.trans-former.fr/pratiques-regeneratives`
|
||||||
|
2. Attendre 3s (Leaflet init)
|
||||||
|
3. `snapshot` — décrire ce qui est visible : carte Europe, sidebar gauche, onglet header actif
|
||||||
|
4. Vérifier dans le DOM :
|
||||||
|
- Présence d'au moins 1 marker Leaflet (chercher `.leaflet-marker-icon` ou `.leaflet-pane`)
|
||||||
|
- Sidebar visible avec des cartes de pratiques (chercher titres/noms)
|
||||||
|
- Bandeau DOM-TOM présent en bas (textes Guadeloupe/Martinique/Réunion...)
|
||||||
|
5. Ouvrir la console (`evaluate` `window.__AEP_DEBUG__ || 'no debug'`) — noter les erreurs JS si présentes
|
||||||
|
6. `evaluate` `fetch('/api/pratiques').then(r=>r.json()).then(d=>d.list.length)` — doit retourner 52
|
||||||
|
|
||||||
|
Bugs à surveiller :
|
||||||
|
- Carte blanche (Leaflet tiles non chargés)
|
||||||
|
- Markers manquants ou hors viewport
|
||||||
|
- CSS `--nav-primary-solid` non résolu (barre nav sans couleur)
|
||||||
|
- Erreurs console "leaflet", "vue warn", "hydration mismatch"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scenario 2 — Filtres sidebar (criteres + pays + type)
|
||||||
|
|
||||||
|
**Objectif :** vérifier que les filtres réduisent bien les résultats.
|
||||||
|
|
||||||
|
Étapes :
|
||||||
|
1. Depuis `/pratiques-regeneratives` desktop, noter le nombre de fiches dans la sidebar (label "X résultats" ou count visible)
|
||||||
|
2. Cliquer sur le chip "Matériaux" dans les filtres critères
|
||||||
|
3. Attendre 500ms — `snapshot` — noter le nouveau count
|
||||||
|
4. Cliquer sur le chip "Pays" (ou sélectionner France dans le filtre pays si c'est un select)
|
||||||
|
5. `snapshot` — noter le count (doit être inférieur ou égal à l'étape 3)
|
||||||
|
6. Cliquer sur le type "Coopérative" si accessible
|
||||||
|
7. `snapshot` — noter le count
|
||||||
|
8. Cliquer "Réinitialiser" ou le bouton reset — vérifier que le count revient au total (52)
|
||||||
|
|
||||||
|
Bugs à surveiller :
|
||||||
|
- Filtre appliqué mais count ne change pas
|
||||||
|
- Markers sur la carte non synchronisés avec la sidebar
|
||||||
|
- Reset qui ne fonctionne pas
|
||||||
|
- Chip actif sans indicateur visuel
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scenario 3 — Fiche pratique (clic marker ou card + retour)
|
||||||
|
|
||||||
|
**Objectif :** vérifier que l'ouverture d'une fiche fonctionne et que le retour préserve les filtres.
|
||||||
|
|
||||||
|
Étapes :
|
||||||
|
1. Appliquer un filtre (ex: "Matériaux") pour avoir < 52 résultats
|
||||||
|
2. Cliquer sur une card dans la sidebar OU un marker sur la carte
|
||||||
|
3. `snapshot` — décrire ce qui est affiché : modal ? Page fiche ? URL change ?
|
||||||
|
4. Vérifier que la fiche contient : nom, description, critères, type, pays, URL (si disponible)
|
||||||
|
5. Cliquer le bouton "Retour" ou `navigateBack`
|
||||||
|
6. `snapshot` — vérifier que le filtre "Matériaux" est toujours actif et le count identique à avant
|
||||||
|
|
||||||
|
Bugs à surveiller :
|
||||||
|
- Clic sur card ne navigue pas
|
||||||
|
- Page fiche vide ou 404
|
||||||
|
- Bouton retour recharge sans filtres
|
||||||
|
- Scroll position perdu
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scenario 4 — Formulaire de contribution
|
||||||
|
|
||||||
|
**Objectif :** soumettre une proposition valide et vérifier le message de succès.
|
||||||
|
|
||||||
|
Étapes :
|
||||||
|
1. `navigate` vers `https://aep.trans-former.fr/proposer-pratique`
|
||||||
|
2. `snapshot` — décrire le formulaire visible
|
||||||
|
3. Remplir les champs suivants :
|
||||||
|
- `#nom` : "Test BrowserMCP E2E"
|
||||||
|
- `#url` : "https://example.com/test-e2e"
|
||||||
|
- `#description_user` : "Pratique de test soumise automatiquement par BrowserMCP pour valider le pipeline de contribution de l'onglet Pratiques régénératives. Cette description fait plus de 100 caractères."
|
||||||
|
- Critères : cocher au moins 3 checkboxes (ex: "Matériaux", "Posture", "Vivant")
|
||||||
|
- Type d'entité : sélectionner "Collectif"
|
||||||
|
- Pays : sélectionner "France" dans le select
|
||||||
|
4. `snapshot` avant envoi — vérifier que les champs sont remplis
|
||||||
|
5. Cliquer le bouton `submit` du formulaire
|
||||||
|
6. Attendre 3s
|
||||||
|
7. `snapshot` — chercher le message de succès "Merci !" + "Ta proposition est en attente de modération."
|
||||||
|
|
||||||
|
Bugs à surveiller :
|
||||||
|
- Validation côté client ne se déclenche pas
|
||||||
|
- Submit retourne une erreur réseau (verifier dans console : `fetch POST /api/submit-pratique`)
|
||||||
|
- Message succès ne s'affiche pas
|
||||||
|
- Rate limit 429 (normal si on a soumis plusieurs fois — attendre 1h ou ignorer si premier test)
|
||||||
|
|
||||||
|
Note : si rate limit 429, c'est le comportement attendu. Réessayer en changeant légèrement le nom.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scenario 5 — Mobile (viewport 375x667)
|
||||||
|
|
||||||
|
**Objectif :** valider l'expérience mobile de base.
|
||||||
|
|
||||||
|
Étapes :
|
||||||
|
1. Changer le viewport à 375x667 (iPhone SE) via `setViewport` ou équivalent BrowserMCP
|
||||||
|
2. `navigate` vers `https://aep.trans-former.fr/pratiques-regeneratives`
|
||||||
|
3. Attendre 3s
|
||||||
|
4. `snapshot` — décrire : sheet en bas ? Carte en fond plein écran ? Header avec hamburger ?
|
||||||
|
5. Tenter d'interagir avec la sheet du bas (si elle existe) : tapper sur le header de la sheet pour l'agrandir
|
||||||
|
6. `snapshot` — sheet en mode "half" ou "full" ?
|
||||||
|
7. Vérifier que les filtres sont accessibles (dans la sheet ou dans un drawer)
|
||||||
|
8. Cliquer une card si visible
|
||||||
|
9. `snapshot` — fiche pleine page ou modal plein écran ?
|
||||||
|
10. `navigate` vers `https://aep.trans-former.fr/proposer-pratique`
|
||||||
|
11. `snapshot` — formulaire lisible et accessible sur mobile ?
|
||||||
|
|
||||||
|
Bugs à surveiller :
|
||||||
|
- Sheet absente ou non draggable
|
||||||
|
- Carte non visible derrière la sheet
|
||||||
|
- Hamburger nav manquant
|
||||||
|
- Formulaire /proposer-pratique avec overflow horizontal
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Format de récap final attendu
|
||||||
|
|
||||||
|
```
|
||||||
|
Cascade onglet 1 — E2E [PASS / FAIL avec [N] bugs H]
|
||||||
|
|
||||||
|
Scénarios : [N]/5 OK
|
||||||
|
Bugs : [N] High / [N] Medium / [N] Low
|
||||||
|
Erreurs console : [N] (liste si > 0)
|
||||||
|
|
||||||
|
Détail par scénario :
|
||||||
|
S1 Carte desktop : [PASS/FAIL] — [note courte]
|
||||||
|
S2 Filtres : [PASS/FAIL] — [note courte]
|
||||||
|
S3 Fiche + retour: [PASS/FAIL] — [note courte]
|
||||||
|
S4 Form submit : [PASS/FAIL] — [note courte]
|
||||||
|
S5 Mobile : [PASS/FAIL] — [note courte]
|
||||||
|
|
||||||
|
Bugs H :
|
||||||
|
- [description + étape]
|
||||||
|
|
||||||
|
Bugs M :
|
||||||
|
- [description + étape]
|
||||||
|
|
||||||
|
Bugs L :
|
||||||
|
- [description + étape]
|
||||||
|
|
||||||
|
Screenshots : [N] pris
|
||||||
|
```
|
||||||
|
|
||||||
|
Envoyer ce récap à Jules (copier dans la session pilote ou dans `aep-communaute-build/E2E-RESULTS.md`).
|
||||||
@@ -7,62 +7,55 @@
|
|||||||
style="background: var(--nav-surface); border-bottom: 1px solid var(--nav-bg-alt);"
|
style="background: var(--nav-surface); border-bottom: 1px solid var(--nav-bg-alt);"
|
||||||
>
|
>
|
||||||
<!-- Logo -->
|
<!-- Logo -->
|
||||||
<a href="/" class="logo-link flex items-center gap-2 hover:opacity-90 transition-opacity shrink-0" title="Architecture d'Écologie Politique">
|
<a href="/" class="flex items-center gap-2 hover:opacity-90 transition-opacity shrink-0 group relative" title="Architecture d'Écologie Politique">
|
||||||
<div
|
<div
|
||||||
class="h-8 px-2 rounded-lg flex items-center justify-center shrink-0"
|
class="h-7 px-2 rounded-lg flex items-center justify-center shrink-0"
|
||||||
style="background: var(--nav-primary-solid);"
|
style="background: var(--nav-primary-solid);"
|
||||||
>
|
>
|
||||||
<span class="font-bold text-xs tracking-tight" style="color: var(--nav-text-on-primary);">AEP</span>
|
<span class="font-bold text-xs tracking-tight" style="color: var(--nav-text-on-primary);">AEP</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="logo-text flex flex-col leading-tight">
|
<div class="flex flex-col">
|
||||||
<span class="logo-line-1 font-bold tracking-tight" style="color: var(--nav-text);">Architecture</span>
|
<span class="font-bold text-base tracking-tight leading-tight" style="color: var(--nav-text);">AEP</span>
|
||||||
<span class="logo-line-2 font-bold tracking-tight" style="color: var(--nav-text);">d'Écologie Politique</span>
|
<span class="text-xs leading-tight hidden lg:inline" style="color: var(--nav-text-muted);">Architecture d'Écologie Politique</span>
|
||||||
|
</div>
|
||||||
|
<!-- Tooltip sm (quand le sous-titre lg est caché) -->
|
||||||
|
<div class="absolute left-0 top-full mt-2 px-2 py-1 rounded text-xs whitespace-nowrap pointer-events-none opacity-0 group-hover:opacity-100 transition-opacity lg:hidden z-50"
|
||||||
|
style="background: var(--nav-primary-solid); color: var(--nav-text-on-primary);">
|
||||||
|
Architecture d'Écologie Politique
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<!-- ── Onglets desktop (≥1024px) — remplace la barre de recherche ── -->
|
<!-- ── Onglets desktop (≥1024px) — remplace la barre de recherche ── -->
|
||||||
<nav class="hidden lg:flex flex-1 justify-center items-end gap-0 mx-6" aria-label="Navigation projets">
|
<nav class="hidden lg:flex flex-1 justify-center items-end gap-0 mx-6" aria-label="Navigation projets">
|
||||||
<NuxtLink
|
|
||||||
to="/outils"
|
|
||||||
class="nav-tab"
|
|
||||||
:class="{ 'nav-tab--active': route.path === '/outils' }"
|
|
||||||
>
|
|
||||||
Outils
|
|
||||||
</NuxtLink>
|
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
to="/"
|
to="/"
|
||||||
class="nav-tab"
|
class="nav-tab"
|
||||||
:class="{ 'nav-tab--active': route.path === '/' }"
|
:class="{ 'nav-tab--active': route.path === '/' }"
|
||||||
>
|
>
|
||||||
Écosystème Entraide
|
Écosystème Entraide Architecture
|
||||||
|
</NuxtLink>
|
||||||
|
<NuxtLink
|
||||||
|
to="/pratiques-regeneratives"
|
||||||
|
class="nav-tab"
|
||||||
|
:class="{ 'nav-tab--active': route.path.startsWith('/pratiques-regeneratives') || route.path.startsWith('/pratique/') }"
|
||||||
|
>
|
||||||
|
Pratiques régé
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
to="/agences"
|
to="/agences"
|
||||||
class="nav-tab"
|
class="nav-tab"
|
||||||
:class="{ 'nav-tab--active': route.path === '/agences' }"
|
:class="{ 'nav-tab--active': route.path === '/agences' }"
|
||||||
>
|
>
|
||||||
Réseaux AEP
|
Agences Inspirantes
|
||||||
|
<span class="nav-tab-badge">en construction</span>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
to="/trouver-du-taf"
|
to="/rag"
|
||||||
class="nav-tab"
|
class="nav-tab"
|
||||||
:class="{ 'nav-tab--active': route.path === '/trouver-du-taf' }"
|
:class="{ 'nav-tab--active': route.path === '/rag' }"
|
||||||
>
|
>
|
||||||
Jobs
|
RAG
|
||||||
</NuxtLink>
|
<span class="nav-tab-badge">en construction</span>
|
||||||
<NuxtLink
|
|
||||||
to="/codev"
|
|
||||||
class="nav-tab"
|
|
||||||
:class="{ 'nav-tab--active': route.path.startsWith('/codev') }"
|
|
||||||
>
|
|
||||||
Codev
|
|
||||||
</NuxtLink>
|
|
||||||
<NuxtLink
|
|
||||||
to="/media"
|
|
||||||
class="nav-tab"
|
|
||||||
:class="{ 'nav-tab--active': route.path.startsWith('/media') }"
|
|
||||||
>
|
|
||||||
recherche-média
|
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
@@ -100,13 +93,6 @@
|
|||||||
|
|
||||||
<!-- Actions droite -->
|
<!-- Actions droite -->
|
||||||
<div class="flex items-center gap-2 shrink-0">
|
<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
|
<NuxtLink
|
||||||
to="/a-propos"
|
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"
|
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"
|
||||||
@@ -121,9 +107,9 @@
|
|||||||
>
|
>
|
||||||
Signaler
|
Signaler
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<!-- Proposer une ressource (5 onglets) -->
|
<!-- Proposer une ressource -->
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
to="/proposer"
|
to="/contribuer"
|
||||||
class="px-3 py-1.5 rounded-lg text-sm font-semibold transition-all hover:opacity-80 hidden sm:inline-flex items-center gap-1"
|
class="px-3 py-1.5 rounded-lg text-sm font-semibold transition-all hover:opacity-80 hidden sm:inline-flex items-center gap-1"
|
||||||
style="background: var(--nav-accent); color: var(--nav-text);"
|
style="background: var(--nav-accent); color: var(--nav-text);"
|
||||||
>
|
>
|
||||||
@@ -150,21 +136,21 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Mobile : proposer icône -->
|
<!-- Mobile : contribuer icône -->
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
to="/proposer"
|
to="/contribuer"
|
||||||
class="sm:hidden p-2 rounded-lg"
|
class="sm:hidden p-2 rounded-lg"
|
||||||
style="background: var(--nav-accent); color: var(--nav-text);"
|
style="background: var(--nav-accent); color: var(--nav-text);"
|
||||||
title="Proposer une ressource"
|
title="Contribuer une fiche"
|
||||||
aria-label="Proposer"
|
aria-label="Contribuer"
|
||||||
>
|
>
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
|
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
|
||||||
</svg>
|
</svg>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
|
|
||||||
<!-- Menu déroulant — toutes largeurs (les onglets n'y apparaissent qu'en dessous de 1024px) ; porte « Soutenir » -->
|
<!-- Hamburger mobile (lg:hidden) — toujours en dernier à droite -->
|
||||||
<div class="relative">
|
<div class="lg:hidden relative">
|
||||||
<button
|
<button
|
||||||
@click="hamburgerOpen = !hamburgerOpen"
|
@click="hamburgerOpen = !hamburgerOpen"
|
||||||
class="p-2 rounded-lg transition-all hover:opacity-80"
|
class="p-2 rounded-lg transition-all hover:opacity-80"
|
||||||
@@ -185,16 +171,12 @@
|
|||||||
style="background: var(--nav-surface); border: 1px solid var(--nav-bg-alt); z-index: 9999;"
|
style="background: var(--nav-surface); border: 1px solid var(--nav-bg-alt); z-index: 9999;"
|
||||||
@click="hamburgerOpen = false"
|
@click="hamburgerOpen = false"
|
||||||
>
|
>
|
||||||
<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="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="/" 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="/pratiques-regeneratives" class="block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" :style="route.path.startsWith('/pratiques-regeneratives') || route.path.startsWith('/pratique/') ? 'color: var(--nav-primary-solid); font-weight: 700;' : 'color: var(--nav-text);'">Pratiques régé</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="/agences" class="block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" style="color: var(--nav-text);">Agences Inspirantes</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="/rag" class="block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" style="color: var(--nav-text);">RAG</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>
|
<div style="height: 1px; background: var(--nav-bg-alt); margin: 4px 0;"></div>
|
||||||
<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>
|
<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 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>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
@@ -202,10 +184,13 @@
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<!-- Contenu page (flex-1 pour remplir l'espace) -->
|
<!-- Contenu page (flex-1 pour remplir l'espace) -->
|
||||||
<div class="flex-1" :class="route.path === '/' ? 'overflow-hidden' : 'overflow-y-auto'">
|
<div class="flex-1" :class="(route.path === '/' || route.path === '/pratiques-regeneratives') ? 'overflow-hidden' : 'overflow-y-auto'">
|
||||||
<NuxtPage />
|
<NuxtPage />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Bandeau bas — transparence IA + soutien + compteurs semaine -->
|
||||||
|
<BandeauBas />
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -271,21 +256,6 @@ function goRandom() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
/* ── Logo header (texte 2 lignes) ─────────────────────────────────────── */
|
|
||||||
.logo-text {
|
|
||||||
line-height: 1.05;
|
|
||||||
}
|
|
||||||
.logo-line-1, .logo-line-2 {
|
|
||||||
font-size: 0.7rem;
|
|
||||||
letter-spacing: -0.01em;
|
|
||||||
}
|
|
||||||
@media (min-width: 640px) {
|
|
||||||
.logo-line-1, .logo-line-2 { font-size: 0.78rem; }
|
|
||||||
}
|
|
||||||
@media (min-width: 1024px) {
|
|
||||||
.logo-line-1, .logo-line-2 { font-size: 0.85rem; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Onglets header desktop ───────────────────────────────────────────── */
|
/* ── Onglets header desktop ───────────────────────────────────────────── */
|
||||||
.nav-tab {
|
.nav-tab {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|||||||
+16
-45
@@ -3,44 +3,28 @@
|
|||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
/* ── Palette NAV V2 — Sobre institutionnel ─────────────────────────────── */
|
/* ── 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 {
|
:root {
|
||||||
/* Fonds */
|
/* Fonds */
|
||||||
--nav-bg: var(--bg);
|
--nav-bg: #f8f6f1;
|
||||||
--nav-bg-alt: var(--bg-alt);
|
--nav-bg-alt: #eee9df;
|
||||||
--nav-surface: var(--surface);
|
--nav-surface: #ffffff;
|
||||||
|
|
||||||
/* Bleu nuit — 60% d'opacité sur les surfaces */
|
/* Bleu nuit — 60% d'opacité sur les surfaces */
|
||||||
--nav-primary-raw: 26, 34, 56;
|
--nav-primary-raw: 26, 34, 56;
|
||||||
--nav-primary: var(--primary);
|
--nav-primary: rgba(26, 34, 56, 0.6);
|
||||||
--nav-primary-solid: #1a2238;
|
--nav-primary-solid: #1a2238;
|
||||||
|
|
||||||
/* Accent safran */
|
/* Accent safran */
|
||||||
--nav-accent: var(--accent);
|
--nav-accent: #f5b342;
|
||||||
--nav-accent-soft: rgba(245, 179, 66, 0.85);
|
--nav-accent-soft: rgba(245, 179, 66, 0.85);
|
||||||
|
|
||||||
/* Texte */
|
/* Texte */
|
||||||
--nav-text: var(--text);
|
--nav-text: #1a2238;
|
||||||
--nav-text-muted: var(--text-muted);
|
--nav-text-muted: rgba(26, 34, 56, 0.55);
|
||||||
--nav-text-on-primary: #f8f6f1;
|
--nav-text-on-primary: #f8f6f1;
|
||||||
|
|
||||||
/* Typographie */
|
/* Typographie */
|
||||||
--nav-font: var(--font-body);
|
--nav-font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
@@ -74,25 +58,25 @@
|
|||||||
|
|
||||||
/* Focus ring accessibilité */
|
/* Focus ring accessibilité */
|
||||||
*:focus-visible {
|
*:focus-visible {
|
||||||
outline: 2px solid var(--focus-ring);
|
outline: 2px solid var(--nav-accent);
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Dark mode ──────────────────────────────────────────────────────────── */
|
/* ── Dark mode ──────────────────────────────────────────────────────────── */
|
||||||
.dark {
|
.dark {
|
||||||
--nav-bg: var(--bg);
|
--nav-bg: #111520;
|
||||||
--nav-bg-alt: var(--bg-alt);
|
--nav-bg-alt: #1e2538;
|
||||||
--nav-surface: var(--surface);
|
--nav-surface: #1a2238;
|
||||||
|
|
||||||
--nav-primary-raw: 200, 210, 240;
|
--nav-primary-raw: 200, 210, 240;
|
||||||
--nav-primary: var(--primary);
|
--nav-primary: rgba(200, 210, 240, 0.15);
|
||||||
--nav-primary-solid: #c8d2f0;
|
--nav-primary-solid: #c8d2f0;
|
||||||
|
|
||||||
--nav-accent: var(--accent);
|
--nav-accent: #f5b342;
|
||||||
--nav-accent-soft: rgba(245, 179, 66, 0.85);
|
--nav-accent-soft: rgba(245, 179, 66, 0.85);
|
||||||
|
|
||||||
--nav-text: var(--text);
|
--nav-text: #e8ecf5;
|
||||||
--nav-text-muted: var(--text-muted);
|
--nav-text-muted: rgba(200, 210, 240, 0.55);
|
||||||
--nav-text-on-primary: #111520;
|
--nav-text-on-primary: #111520;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,16 +108,3 @@
|
|||||||
.dark .leaflet-popup-tip {
|
.dark .leaflet-popup-tip {
|
||||||
background: var(--nav-surface);
|
background: var(--nav-surface);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Rendu Markdown chatbot (useMarkdown composable) ────────────────────── */
|
|
||||||
.md-content { font-size: inherit; line-height: 1.6; }
|
|
||||||
.md-content p { margin: 0 0 0.5em; }
|
|
||||||
.md-content p:last-child { margin-bottom: 0; }
|
|
||||||
.md-content strong, .md-h1, .md-h2, .md-h3 { font-weight: 700; }
|
|
||||||
.md-h2 { font-size: 0.9375em; display: block; margin-bottom: 0.25em; }
|
|
||||||
.md-h3 { font-size: 0.875em; display: block; }
|
|
||||||
.md-content em { font-style: italic; }
|
|
||||||
.md-list { margin: 0.375em 0 0.375em 1em; padding: 0; list-style: disc; }
|
|
||||||
.md-list li { margin-bottom: 0.2em; }
|
|
||||||
.md-link { text-decoration: underline; opacity: 0.85; }
|
|
||||||
.md-link:hover { opacity: 1; }
|
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
/* Palette familles V2 - variables locales, ne pas toucher --nav-* */
|
|
||||||
:root {
|
|
||||||
--bifurc-color-f1: #a85d3e; /* Réemploi & filières - terracotta */
|
|
||||||
--bifurc-color-f2: #c4a472; /* Frugalité & low-tech - terre crue */
|
|
||||||
--bifurc-color-f3: #d4a017; /* Architecture sociale - ocre */
|
|
||||||
--bifurc-color-f4: #5a7a4a; /* Collectifs & AMO - vert mousse */
|
|
||||||
--bifurc-color-f5: #3d6a8c; /* Urbanisme transition - bleu profond */
|
|
||||||
|
|
||||||
--bifurc-badge-f6: #6b3fa0; /* Recherche politique - violet */
|
|
||||||
--bifurc-badge-cr: #2d8a6b; /* Centre ressources - vert foncé */
|
|
||||||
--bifurc-badge-mm: #c44a2f; /* Mouvement manifeste - rouge brique */
|
|
||||||
--bifurc-badge-cp: #1a3a6b; /* Contre-pouvoir - bleu nuit */
|
|
||||||
|
|
||||||
--bifurc-banner-bg: #faf8f5;
|
|
||||||
--bifurc-banner-border: #e0d8cc;
|
|
||||||
--bifurc-banner-text: #2c2416;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bifurc-pin-f1 { background: var(--bifurc-color-f1); }
|
|
||||||
.bifurc-pin-f2 { background: var(--bifurc-color-f2); }
|
|
||||||
.bifurc-pin-f3 { background: var(--bifurc-color-f3); }
|
|
||||||
.bifurc-pin-f4 { background: var(--bifurc-color-f4); }
|
|
||||||
.bifurc-pin-f5 { background: var(--bifurc-color-f5); }
|
|
||||||
@@ -0,0 +1,587 @@
|
|||||||
|
<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>
|
||||||
|
|
||||||
|
<!-- ═══════════════════════════════════════ FAB MOBILE (< 1024px) ════════ -->
|
||||||
|
<div v-else>
|
||||||
|
<!-- FAB soutenir (à gauche du chatbot) -->
|
||||||
|
<button
|
||||||
|
class="fab-soutenir"
|
||||||
|
type="button"
|
||||||
|
@click="fabSheetOpen = true"
|
||||||
|
aria-label="Soutenir le projet AEP"
|
||||||
|
>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Bottom sheet FAB -->
|
||||||
|
<Teleport to="body">
|
||||||
|
<Transition name="backdrop">
|
||||||
|
<div
|
||||||
|
v-if="fabSheetOpen"
|
||||||
|
class="fixed inset-0 z-[1020]"
|
||||||
|
style="background: rgba(26,34,56,0.5);"
|
||||||
|
@click="fabSheetOpen = false"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</Transition>
|
||||||
|
<Transition name="sheet">
|
||||||
|
<div
|
||||||
|
v-if="fabSheetOpen"
|
||||||
|
class="fab-sheet"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="Soutenir AEP"
|
||||||
|
>
|
||||||
|
<!-- Poignée -->
|
||||||
|
<div class="flex justify-center pt-3 pb-1">
|
||||||
|
<div class="rounded-full" style="width: 36px; height: 4px; background: var(--nav-bg-alt);" />
|
||||||
|
</div>
|
||||||
|
<div class="px-5 pb-6">
|
||||||
|
<h2 class="text-base font-bold mb-2" style="color: var(--nav-text);">Soutenir AEP</h2>
|
||||||
|
<template v-if="stats">
|
||||||
|
<p class="text-sm mb-1" style="color: var(--nav-text-muted);">
|
||||||
|
Coût IA ce mois : <strong>{{ stats.cout_mois_eur.toFixed(2) }} €</strong>
|
||||||
|
· Tokens : {{ stats.tokens_mois.toLocaleString('fr-FR') }}
|
||||||
|
</p>
|
||||||
|
<p class="text-sm mb-3" style="color: var(--nav-text-muted);">
|
||||||
|
{{ stats.fiches_semaine }} fiche{{ stats.fiches_semaine !== 1 ? 's' : '' }} ajoutée{{ stats.fiches_semaine !== 1 ? 's' : '' }} cette semaine
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
<p class="text-sm mb-4" style="color: var(--nav-text-muted); line-height: 1.5;">
|
||||||
|
1 € = 30 fiches mises en ligne. AEP est libre, sans pub, financé par les dons.
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
href="https://liberapay.com/trans-former.fr/donate"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="block w-full text-center py-3 rounded-xl font-semibold text-sm"
|
||||||
|
style="background: var(--nav-primary); color: var(--nav-text-on-primary); text-decoration: none;"
|
||||||
|
@click="fabSheetOpen = false"
|
||||||
|
>
|
||||||
|
Soutenir sur Liberapay →
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</Teleport>
|
||||||
|
</div>
|
||||||
|
</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 fabSheetOpen = 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── FAB mobile soutenir ─────────────────────────────────────────────────── */
|
||||||
|
.fab-soutenir {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 68px; /* au-dessus du FAB chatbot à 24px du bas + 48px de hauteur */
|
||||||
|
left: 16px;
|
||||||
|
z-index: 1000;
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: none;
|
||||||
|
background: var(--nav-accent);
|
||||||
|
color: var(--nav-text);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-shadow: 0 4px 16px rgba(26,34,56,0.25);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.15s, transform 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fab-soutenir:hover { opacity: 0.88; transform: translateY(-1px); }
|
||||||
|
|
||||||
|
/* ── Bottom sheet FAB ────────────────────────────────────────────────────── */
|
||||||
|
.fab-sheet {
|
||||||
|
position: fixed;
|
||||||
|
inset-x: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 1021;
|
||||||
|
background: var(--nav-surface);
|
||||||
|
border-radius: 16px 16px 0 0;
|
||||||
|
box-shadow: 0 -4px 32px rgba(26,34,56,0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 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>
|
||||||
@@ -1,372 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div style="width: 100%; height: 100%; position: relative; background: #f5f3f0;">
|
|
||||||
<svg ref="svgRef" style="width: 100%; height: 100%;"></svg>
|
|
||||||
<div ref="tooltipRef" style="
|
|
||||||
position: absolute; pointer-events: none;
|
|
||||||
background: var(--nav-surface); border: 1px solid var(--nav-bg-alt);
|
|
||||||
border-radius: 6px; padding: 8px 12px; font-size: 0.78rem;
|
|
||||||
color: var(--nav-text); max-width: 240px; box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
|
||||||
opacity: 0; transition: opacity 0.15s; z-index: 100;
|
|
||||||
"></div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
interface EcoleData { id: string; label: string; description: string; color: string; x_hint: number; y_hint: number }
|
|
||||||
interface LivreRag { slug: string; titre: string; annee: number; couches: string[] }
|
|
||||||
interface AuteurData { id: string; nom: string; dates: string; ecoles: string[]; ecole_principale: string; ingere: boolean; livres_rag: LivreRag[]; theses_cles: string[]; bio_courte: string; bio_courte_provisoire?: string }
|
|
||||||
interface PenseesData { ecoles: EcoleData[]; auteurs: AuteurData[] }
|
|
||||||
|
|
||||||
// Liens d'influence inter-ecoles (Phase 7 - matrice de filiation)
|
|
||||||
const LINKS_INFLUENCE = [
|
|
||||||
// filiations directes
|
|
||||||
{ source: 'eco-anarchisme', target: 'technocritique', auteurs_passerelle: ['Bookchin', 'Illich'], type: 'filiation' },
|
|
||||||
{ source: 'eco-anarchisme', target: 'decroissance', auteurs_passerelle: ['Latouche', 'Kropotkine'], type: 'filiation' },
|
|
||||||
{ source: 'ecosocialisme', target: 'decroissance', auteurs_passerelle: ['Saito', 'Gorz'], type: 'filiation' },
|
|
||||||
{ source: 'ecosocialisme', target: 'ecologies-decoloniales', auteurs_passerelle: ['Klein', 'Ferdinand'], type: 'filiation' },
|
|
||||||
{ source: 'ecofeminismes', target: 'ecologies-decoloniales', auteurs_passerelle: ['Shiva', 'Ouassak'], type: 'filiation' },
|
|
||||||
{ source: 'ecofeminismes', target: 'pensees-vivant', auteurs_passerelle: ['Haraway', 'Despret'], type: 'filiation' },
|
|
||||||
{ source: 'technocritique', target: 'decroissance', auteurs_passerelle: ['Ellul', 'Latouche'], type: 'filiation' },
|
|
||||||
{ source: 'decroissance', target: 'pensees-vivant', auteurs_passerelle: ['Servigne', 'Despret'], type: 'filiation' },
|
|
||||||
{ source: 'pensees-vivant', target: 'ethiques-environnementales', auteurs_passerelle: ['Naess', 'Latour'], type: 'filiation' },
|
|
||||||
{ source: 'ecosocialisme', target: 'eco-anarchisme', auteurs_passerelle: ['Gorz', 'Graeber'], type: 'filiation' },
|
|
||||||
// liens de critique
|
|
||||||
{ source: 'ecosocialisme', target: 'capitalisme-vert', auteurs_passerelle: ['Klein', 'Malm'], type: 'critique' },
|
|
||||||
{ source: 'decroissance', target: 'capitalisme-vert', auteurs_passerelle: ['Latouche', 'Meadows'], type: 'critique' },
|
|
||||||
{ source: 'eco-anarchisme', target: 'capitalisme-vert', auteurs_passerelle: ['Bookchin'], type: 'critique' },
|
|
||||||
{ source: 'ethiques-environnementales', target: 'ecofascismes', auteurs_passerelle: ['Naess'], type: 'critique' },
|
|
||||||
{ source: 'capitalisme-vert', target: 'ecofascismes', auteurs_passerelle: [], type: 'critique' },
|
|
||||||
]
|
|
||||||
|
|
||||||
const props = defineProps<{ data: PenseesData | null; active?: boolean }>()
|
|
||||||
const emit = defineEmits<{ 'select-auteur': [id: string]; 'select-ecole': [id: string] }>()
|
|
||||||
|
|
||||||
const svgRef = ref<SVGElement | null>(null)
|
|
||||||
const tooltipRef = ref<HTMLElement | null>(null)
|
|
||||||
let simulation: any = null
|
|
||||||
let d3LinkSel: any = null
|
|
||||||
let d3InfluenceSel: any = null
|
|
||||||
let d3NodeSel: any = null
|
|
||||||
let d3EdgeLabelSel: any = null
|
|
||||||
|
|
||||||
async function initGraph() {
|
|
||||||
if (!svgRef.value || !props.data) return
|
|
||||||
const d3 = await import('d3')
|
|
||||||
|
|
||||||
const svgEl = svgRef.value
|
|
||||||
const W = svgEl.clientWidth || 900
|
|
||||||
const H = svgEl.clientHeight || 600
|
|
||||||
|
|
||||||
d3.select(svgEl).selectAll('*').remove()
|
|
||||||
const svg = d3.select(svgEl).attr('viewBox', `0 0 ${W} ${H}`)
|
|
||||||
const g = svg.append('g')
|
|
||||||
|
|
||||||
svg.call(d3.zoom<SVGElement, unknown>().scaleExtent([0.3, 4]).on('zoom', (e) => g.attr('transform', e.transform)) as any)
|
|
||||||
|
|
||||||
const ecoleMap = new Map<string, EcoleData>(props.data.ecoles.map(e => [e.id, e]))
|
|
||||||
|
|
||||||
// Positions fixes des ecoles (base pour forces D3)
|
|
||||||
const ecolePositions = new Map<string, { tx: number; ty: number }>()
|
|
||||||
props.data.ecoles.forEach(e => {
|
|
||||||
ecolePositions.set(e.id, { tx: W * e.x_hint, ty: H * e.y_hint })
|
|
||||||
})
|
|
||||||
|
|
||||||
// ---- LIENS D'INFLUENCE INTER-ECOLES (couche 3) ----
|
|
||||||
const gInfluence = g.append('g').attr('class', 'links-influence')
|
|
||||||
|
|
||||||
LINKS_INFLUENCE.forEach(link => {
|
|
||||||
const src = ecolePositions.get(link.source)
|
|
||||||
const tgt = ecolePositions.get(link.target)
|
|
||||||
if (!src || !tgt) return
|
|
||||||
|
|
||||||
const isCritique = link.type === 'critique'
|
|
||||||
const lineEl = gInfluence.append('line')
|
|
||||||
.attr('class', 'influence-link')
|
|
||||||
.attr('x1', src.tx).attr('y1', src.ty)
|
|
||||||
.attr('x2', tgt.tx).attr('y2', tgt.ty)
|
|
||||||
.attr('stroke', isCritique ? '#d99' : '#9aa')
|
|
||||||
.attr('stroke-width', 1)
|
|
||||||
.attr('stroke-dasharray', isCritique ? '4,3' : '6,4')
|
|
||||||
.attr('stroke-opacity', isCritique ? 0.2 : 0.22)
|
|
||||||
|
|
||||||
if (link.auteurs_passerelle && link.auteurs_passerelle.length > 0) {
|
|
||||||
lineEl
|
|
||||||
.on('mouseenter', (e: any) => {
|
|
||||||
if (!tooltipRef.value) return
|
|
||||||
tooltipRef.value.innerHTML = `<strong>Influence</strong><br><span style="opacity:0.8;font-size:0.72rem;">Passerelles : ${link.auteurs_passerelle.join(', ')}</span>`
|
|
||||||
tooltipRef.value.style.opacity = '1'
|
|
||||||
})
|
|
||||||
.on('mousemove', (e: any) => {
|
|
||||||
if (!tooltipRef.value || !svgEl) return
|
|
||||||
const rect = (svgEl as HTMLElement).getBoundingClientRect()
|
|
||||||
tooltipRef.value.style.left = (e.clientX - rect.left + 14) + 'px'
|
|
||||||
tooltipRef.value.style.top = (e.clientY - rect.top - 10) + 'px'
|
|
||||||
})
|
|
||||||
.on('mouseleave', () => { if (tooltipRef.value) tooltipRef.value.style.opacity = '0' })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// ---- SIMULATION D3 (auteurs) ----
|
|
||||||
const auteurNodes: any[] = props.data.auteurs.map(a => {
|
|
||||||
const ecole = ecoleMap.get(a.ecole_principale)
|
|
||||||
const jitter = () => (Math.random() - 0.5) * 80
|
|
||||||
return {
|
|
||||||
id: a.id, type: 'auteur', nom: a.nom, dates: a.dates,
|
|
||||||
bio_courte: a.bio_courte,
|
|
||||||
bio_provisoire: a.bio_courte_provisoire ?? '',
|
|
||||||
ingere: a.ingere,
|
|
||||||
ecole_principale: a.ecole_principale,
|
|
||||||
color: ecole?.color ?? '#888', r: 11,
|
|
||||||
x: W * (ecole?.x_hint ?? 0.5) + jitter(),
|
|
||||||
y: H * (ecole?.y_hint ?? 0.5) + jitter(),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Liens appartenance auteur -> ecole (vers centroid fixe)
|
|
||||||
const links: any[] = []
|
|
||||||
props.data.auteurs.forEach(a => {
|
|
||||||
links.push({ source: a.id, target: a.ecole_principale, strength: 0.65, isSubcourant: false })
|
|
||||||
a.ecoles.filter(e => e !== a.ecole_principale).forEach(e => {
|
|
||||||
links.push({ source: a.id, target: e, strength: 0.25, isSubcourant: true })
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// Nodes fictifs fixes pour les ecoles (cibles des liens appartenance)
|
|
||||||
const ecoleFixedNodes: any[] = props.data.ecoles.map(e => ({
|
|
||||||
id: e.id, type: 'ecole-fixed', ecoleId: e.id,
|
|
||||||
x: W * e.x_hint, y: H * e.y_hint,
|
|
||||||
fx: W * e.x_hint, fy: H * e.y_hint,
|
|
||||||
}))
|
|
||||||
|
|
||||||
// Rayon proportionnel au nombre d'auteurs de l'ecole
|
|
||||||
const ecoleAuteurCounts = new Map<string, number>()
|
|
||||||
props.data.ecoles.forEach(e => ecoleAuteurCounts.set(e.id, 0))
|
|
||||||
props.data.auteurs.forEach(a => ecoleAuteurCounts.set(a.ecole_principale, (ecoleAuteurCounts.get(a.ecole_principale) ?? 0) + 1))
|
|
||||||
const ecoleRadius = (count: number) => Math.max(16, Math.min(36, 13 + count * 1.5))
|
|
||||||
|
|
||||||
const allNodes = [...ecoleFixedNodes, ...auteurNodes]
|
|
||||||
|
|
||||||
if (simulation) simulation.stop()
|
|
||||||
simulation = d3.forceSimulation(allNodes)
|
|
||||||
.force('link', d3.forceLink(links).id((d: any) => d.id).distance(120).strength((d: any) => d.strength ?? 0.5))
|
|
||||||
.force('charge', d3.forceManyBody().strength(-70))
|
|
||||||
.force('center', d3.forceCenter(W / 2, H / 2).strength(0.02))
|
|
||||||
.force('collision', d3.forceCollide().radius((d: any) => d.type === 'ecole-fixed' ? ecoleRadius(ecoleAuteurCounts.get(d.ecoleId) ?? 0) + 4 : 12))
|
|
||||||
.force('forceX', d3.forceX<any>((d: any) => {
|
|
||||||
if (d.type === 'auteur') {
|
|
||||||
const pos = ecolePositions.get(d.ecole_principale)
|
|
||||||
return pos ? pos.tx : W / 2
|
|
||||||
}
|
|
||||||
return W / 2
|
|
||||||
}).strength(0.15))
|
|
||||||
.force('forceY', d3.forceY<any>((d: any) => {
|
|
||||||
if (d.type === 'auteur') {
|
|
||||||
const pos = ecolePositions.get(d.ecole_principale)
|
|
||||||
return pos ? pos.ty : H / 2
|
|
||||||
}
|
|
||||||
return H / 2
|
|
||||||
}).strength(0.15))
|
|
||||||
|
|
||||||
// ---- NOEUDS ECOLES visibles (couche 3.5) ----
|
|
||||||
const gEcoles = g.append('g').attr('class', 'ecoles-nodes')
|
|
||||||
ecoleFixedNodes.forEach(eNode => {
|
|
||||||
const ecole = ecoleMap.get(eNode.ecoleId)
|
|
||||||
if (!ecole) return
|
|
||||||
const count = ecoleAuteurCounts.get(eNode.ecoleId) ?? 0
|
|
||||||
const r = ecoleRadius(count)
|
|
||||||
gEcoles.append('circle')
|
|
||||||
.attr('cx', eNode.fx).attr('cy', eNode.fy).attr('r', r)
|
|
||||||
.attr('fill', ecole.color).attr('fill-opacity', 0.82).attr('stroke', ecole.color).attr('stroke-width', 2)
|
|
||||||
.attr('class', 'ecole-node').style('cursor', 'pointer')
|
|
||||||
.on('mouseenter', (e: any) => {
|
|
||||||
if (!tooltipRef.value) return
|
|
||||||
tooltipRef.value.innerHTML = `<strong>${ecole.label}</strong> <span style="opacity:0.6;font-size:0.7rem;">${count} auteur${count > 1 ? 's' : ''}</span><br><span style="opacity:0.75;font-size:0.72rem;">${ecole.description}</span>`
|
|
||||||
tooltipRef.value.style.opacity = '1'
|
|
||||||
})
|
|
||||||
.on('mousemove', (e: any) => {
|
|
||||||
if (!tooltipRef.value || !svgEl) return
|
|
||||||
const rect = (svgEl as HTMLElement).getBoundingClientRect()
|
|
||||||
tooltipRef.value.style.left = (e.clientX - rect.left + 14) + 'px'
|
|
||||||
tooltipRef.value.style.top = (e.clientY - rect.top - 10) + 'px'
|
|
||||||
})
|
|
||||||
.on('mouseleave', () => { if (tooltipRef.value) tooltipRef.value.style.opacity = '0' })
|
|
||||||
.on('click', (e: any) => { e.stopPropagation(); emit('select-ecole', eNode.ecoleId) })
|
|
||||||
|
|
||||||
// ---- TITRES ECOLES visibles en permanence ----
|
|
||||||
const labelText = ecole.label
|
|
||||||
const words = labelText.split(' ')
|
|
||||||
const fontSize = Math.max(12, r * 0.45)
|
|
||||||
if (words.length > 2 || labelText.length > 12) {
|
|
||||||
const mid = Math.ceil(words.length / 2)
|
|
||||||
const line1 = words.slice(0, mid).join(' ')
|
|
||||||
const line2 = words.slice(mid).join(' ')
|
|
||||||
const textEl = gEcoles.append('text')
|
|
||||||
.attr('x', eNode.fx)
|
|
||||||
.attr('y', eNode.fy)
|
|
||||||
.attr('text-anchor', 'middle')
|
|
||||||
.attr('dominant-baseline', 'middle')
|
|
||||||
.style('pointer-events', 'none')
|
|
||||||
.style('font-weight', '700')
|
|
||||||
.style('font-size', `${fontSize}px`)
|
|
||||||
.style('fill', '#ffffff')
|
|
||||||
.style('text-shadow', '0 1px 3px rgba(0,0,0,0.5)')
|
|
||||||
.style('user-select', 'none')
|
|
||||||
textEl.append('tspan')
|
|
||||||
.attr('x', eNode.fx)
|
|
||||||
.attr('dy', `-${fontSize * 0.6}px`)
|
|
||||||
.text(line1)
|
|
||||||
textEl.append('tspan')
|
|
||||||
.attr('x', eNode.fx)
|
|
||||||
.attr('dy', `${fontSize * 1.2}px`)
|
|
||||||
.text(line2)
|
|
||||||
} else {
|
|
||||||
gEcoles.append('text')
|
|
||||||
.attr('x', eNode.fx)
|
|
||||||
.attr('y', eNode.fy)
|
|
||||||
.attr('text-anchor', 'middle')
|
|
||||||
.attr('dominant-baseline', 'middle')
|
|
||||||
.style('pointer-events', 'none')
|
|
||||||
.style('font-weight', '700')
|
|
||||||
.style('font-size', `${fontSize}px`)
|
|
||||||
.style('fill', '#ffffff')
|
|
||||||
.style('user-select', 'none')
|
|
||||||
.text(labelText)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// ---- LIENS APPARTENANCE (couche 4) ----
|
|
||||||
const gLinks = g.append('g').attr('class', 'links-appartenance')
|
|
||||||
d3LinkSel = gLinks.selectAll('line').data(links).join('line')
|
|
||||||
.attr('stroke', 'rgba(150,150,150,0.28)').attr('stroke-width', 1.2)
|
|
||||||
|
|
||||||
// ---- EDGE LABELS - sous-courants (couche 4b) ----
|
|
||||||
const subcourantLinks = links.filter((l: any) => l.isSubcourant)
|
|
||||||
d3EdgeLabelSel = gLinks.selectAll('text.pensees-edge-label')
|
|
||||||
.data(subcourantLinks)
|
|
||||||
.join('text')
|
|
||||||
.attr('class', 'pensees-edge-label')
|
|
||||||
|
|
||||||
// ---- NODES AUTEURS (couche 5) ----
|
|
||||||
const gAuteurs = g.append('g').attr('class', 'auteurs')
|
|
||||||
d3NodeSel = gAuteurs.selectAll('g').data(auteurNodes).join('g')
|
|
||||||
.style('cursor', (d: any) => d.ingere ? 'pointer' : 'default')
|
|
||||||
.call(d3.drag<any, any>()
|
|
||||||
.on('start', (e: any, d: any) => { if (!e.active) simulation.alphaTarget(0.3).restart(); d.fx = d.x; d.fy = d.y })
|
|
||||||
.on('drag', (e: any, d: any) => { d.fx = e.x; d.fy = e.y })
|
|
||||||
.on('end', (e: any, d: any) => { if (!e.active) simulation.alphaTarget(0); d.fx = null; d.fy = null }))
|
|
||||||
.on('click', (e: any, d: any) => {
|
|
||||||
if (!d.ingere) return
|
|
||||||
e.stopPropagation()
|
|
||||||
emit('select-auteur', d.id)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Phase 8.D : grisage conditionnel auteurs non-ingeres
|
|
||||||
d3NodeSel.append('circle')
|
|
||||||
.attr('r', (d: any) => d.r)
|
|
||||||
.attr('fill', (d: any) => d.ingere ? (d.color + 'cc') : '#bbbbbb')
|
|
||||||
.attr('stroke', (d: any) => d.ingere ? d.color : '#999999')
|
|
||||||
.attr('stroke-width', 1.5)
|
|
||||||
.attr('opacity', (d: any) => d.ingere ? 1 : 0.35)
|
|
||||||
|
|
||||||
// ---- LABELS AUTEURS (couche 6 - drop-shadow blanc) ----
|
|
||||||
d3NodeSel.append('text')
|
|
||||||
.attr('class', 'pensees-auteur-label')
|
|
||||||
.text((d: any) => d.nom.split(' ').pop() ?? d.nom)
|
|
||||||
.attr('text-anchor', 'middle')
|
|
||||||
.attr('dy', (d: any) => -(d.r + 4))
|
|
||||||
.style('pointer-events', 'none')
|
|
||||||
.style('opacity', (d: any) => d.ingere ? 1 : 0.3)
|
|
||||||
.style('fill', (d: any) => d.ingere ? '#1a1a1a' : '#777777')
|
|
||||||
|
|
||||||
d3NodeSel
|
|
||||||
.on('mouseenter', (e: any, d: any) => {
|
|
||||||
if (!tooltipRef.value) return
|
|
||||||
let tooltipHtml = ''
|
|
||||||
if (d.ingere) {
|
|
||||||
const rawBio = d.bio_courte || ''
|
|
||||||
const bio = rawBio.length > 90 ? rawBio.slice(0, 87) + '...' : rawBio
|
|
||||||
tooltipHtml = `<strong>${d.nom}</strong> <span style="opacity:0.6;font-size:0.7rem;">${d.dates}</span><br><span style="opacity:0.75;font-size:0.72rem;">${bio || 'Dans le RAG ATIS.'}</span>`
|
|
||||||
} else {
|
|
||||||
tooltipHtml = `<strong>${d.nom}</strong> <span style="opacity:0.6;font-size:0.7rem;">${d.dates}</span><br><span style="opacity:0.65;font-size:0.72rem;font-style:italic;">Présent dans Bonpote, pas encore ingéré dans le RAG ATIS.</span>`
|
|
||||||
}
|
|
||||||
tooltipRef.value.innerHTML = tooltipHtml
|
|
||||||
tooltipRef.value.style.opacity = '1'
|
|
||||||
})
|
|
||||||
.on('mousemove', (e: any) => {
|
|
||||||
if (!tooltipRef.value || !svgEl) return
|
|
||||||
const rect = (svgEl as HTMLElement).getBoundingClientRect()
|
|
||||||
tooltipRef.value.style.left = (e.clientX - rect.left + 14) + 'px'
|
|
||||||
tooltipRef.value.style.top = (e.clientY - rect.top - 10) + 'px'
|
|
||||||
})
|
|
||||||
.on('mouseleave', () => { if (tooltipRef.value) tooltipRef.value.style.opacity = '0' })
|
|
||||||
|
|
||||||
simulation.on('tick', () => {
|
|
||||||
d3LinkSel
|
|
||||||
.attr('x1', (d: any) => d.source.x).attr('y1', (d: any) => d.source.y)
|
|
||||||
.attr('x2', (d: any) => d.target.x).attr('y2', (d: any) => d.target.y)
|
|
||||||
|
|
||||||
d3EdgeLabelSel
|
|
||||||
.attr('x', (d: any) => (d.source.x + d.target.x) / 2)
|
|
||||||
.attr('y', (d: any) => (d.source.y + d.target.y) / 2)
|
|
||||||
.text((d: any) => {
|
|
||||||
const targetId = typeof d.target === 'object' ? d.target.id : d.target
|
|
||||||
return targetId
|
|
||||||
})
|
|
||||||
|
|
||||||
d3NodeSel.attr('transform', (d: any) => `translate(${d.x},${d.y})`)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(() => props.active, (val) => {
|
|
||||||
if (val && import.meta.client && props.data)
|
|
||||||
requestAnimationFrame(() => requestAnimationFrame(() => initGraph()))
|
|
||||||
})
|
|
||||||
watch(() => props.data, (val) => {
|
|
||||||
if (val && props.active && import.meta.client)
|
|
||||||
requestAnimationFrame(() => requestAnimationFrame(() => initGraph()))
|
|
||||||
})
|
|
||||||
onMounted(async () => {
|
|
||||||
if (import.meta.client && props.data && props.active) {
|
|
||||||
await nextTick()
|
|
||||||
initGraph()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
onUnmounted(() => { if (simulation) simulation.stop() })
|
|
||||||
|
|
||||||
function triggerResize() {
|
|
||||||
if (simulation) {
|
|
||||||
simulation.alpha(0.3).restart()
|
|
||||||
} else if (import.meta.client && props.data && props.active) {
|
|
||||||
initGraph()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
defineExpose({ triggerResize })
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.pensees-auteur-label {
|
|
||||||
fill: #1a1a1a;
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 10px;
|
|
||||||
filter: drop-shadow(0 0 2.5px rgba(255,255,255,0.95));
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pensees-edge-label {
|
|
||||||
fill: #555;
|
|
||||||
font-size: 8.5px;
|
|
||||||
font-style: italic;
|
|
||||||
opacity: 0.7;
|
|
||||||
text-anchor: middle;
|
|
||||||
dominant-baseline: middle;
|
|
||||||
user-select: none;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ecole-node {
|
|
||||||
transition: opacity 0.15s, r 0.15s;
|
|
||||||
}
|
|
||||||
.ecole-node:hover {
|
|
||||||
opacity: 0.75;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,448 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="chatbot-pensees" :class="{ inline: inline }">
|
|
||||||
<!-- Header compact -->
|
|
||||||
<div class="chatbot-header">
|
|
||||||
<div class="chatbot-header-left">
|
|
||||||
<div class="chatbot-icon">
|
|
||||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
||||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<span class="chatbot-title">
|
|
||||||
<template v-if="auteurContext">{{ auteurContext }}</template>
|
|
||||||
<template v-else>Bibliothèque des pensées</template>
|
|
||||||
</span>
|
|
||||||
<span v-if="auteurContext" class="chatbot-corpus-badge">auteur</span>
|
|
||||||
<span v-else class="chatbot-corpus-badge">FRACAS</span>
|
|
||||||
</div>
|
|
||||||
<div class="chatbot-header-right">
|
|
||||||
<button
|
|
||||||
v-if="messages.length > 0"
|
|
||||||
class="chatbot-clear-btn"
|
|
||||||
title="Effacer la conversation"
|
|
||||||
@click="clearMessages"
|
|
||||||
>
|
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" aria-hidden="true">
|
|
||||||
<polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<select v-model="corpusMode" class="chatbot-corpus-select" title="Corpus interrogé">
|
|
||||||
<option value="pensees">Pensées éco.</option>
|
|
||||||
<option value="both">Pensées + Projets</option>
|
|
||||||
<option value="projets">Projets</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Zone messages -->
|
|
||||||
<div ref="messagesContainer" class="chatbot-messages">
|
|
||||||
<!-- Onboarding -->
|
|
||||||
<div v-if="messages.length === 0" class="chatbot-onboarding">
|
|
||||||
<template v-if="auteurContext">
|
|
||||||
<p>Interroge le corpus de <strong>{{ auteurContext }}</strong> — ses livres, ses thèses, ses arguments.</p>
|
|
||||||
<p class="chatbot-onboarding-hint">Ex : "Quelle est sa critique du capitalisme vert ?"</p>
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
<p>Bibliothèque des pensées écologiques — <strong>~140 auteurs FRACAS</strong>, de l'écosocialisme à l'éthique environnementale.</p>
|
|
||||||
<p class="chatbot-onboarding-hint">Ex : "Quelles différences entre décroissance et doughnut économics ?"</p>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Messages -->
|
|
||||||
<template v-for="(msg, i) in messages" :key="i">
|
|
||||||
<div v-if="msg.role === 'user'" class="chatbot-bubble user">{{ msg.content }}</div>
|
|
||||||
<div v-else class="chatbot-bubble assistant">
|
|
||||||
<div class="chatbot-md" v-html="renderMd(msg.content)" />
|
|
||||||
<div v-if="msg.auteur" class="chatbot-auteur-tag">
|
|
||||||
<span>{{ msg.auteur.nom }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- Chargement -->
|
|
||||||
<div v-if="loading" class="chatbot-bubble assistant loading">
|
|
||||||
<span class="dot" /><span class="dot" /><span class="dot" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Erreur -->
|
|
||||||
<div v-if="errorMsg" class="chatbot-error">{{ errorMsg }}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Input -->
|
|
||||||
<div class="chatbot-input-row">
|
|
||||||
<input
|
|
||||||
v-model="inputText"
|
|
||||||
type="text"
|
|
||||||
:disabled="loading"
|
|
||||||
:placeholder="auteurContext ? `Interroger ${auteurContext}…` : 'Pose ta question sur les pensées éco…'"
|
|
||||||
class="chatbot-input"
|
|
||||||
@keydown.enter.prevent="sendMessage"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
:disabled="loading || !inputText.trim()"
|
|
||||||
class="chatbot-send-btn"
|
|
||||||
aria-label="Envoyer"
|
|
||||||
@click="sendMessage"
|
|
||||||
>
|
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
||||||
<line x1="22" y1="2" x2="11" y2="13"/>
|
|
||||||
<polygon points="22 2 15 22 11 13 2 9 22 2"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { useMarkdown } from '~/composables/useMarkdown'
|
|
||||||
|
|
||||||
const { render: renderMd } = useMarkdown()
|
|
||||||
|
|
||||||
interface ChatMessage {
|
|
||||||
role: 'user' | 'assistant'
|
|
||||||
content: string
|
|
||||||
auteur?: { slug: string; nom: string } | null
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
auteurContext: string | null
|
|
||||||
inline?: boolean
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const messages = ref<ChatMessage[]>([])
|
|
||||||
const inputText = ref('')
|
|
||||||
const loading = ref(false)
|
|
||||||
const errorMsg = ref('')
|
|
||||||
const corpusMode = ref<'pensees' | 'projets' | 'both'>('pensees')
|
|
||||||
const messagesContainer = ref<HTMLElement | null>(null)
|
|
||||||
|
|
||||||
watch(() => props.auteurContext, () => {
|
|
||||||
messages.value = []
|
|
||||||
errorMsg.value = ''
|
|
||||||
})
|
|
||||||
|
|
||||||
function clearMessages() {
|
|
||||||
messages.value = []
|
|
||||||
errorMsg.value = ''
|
|
||||||
}
|
|
||||||
|
|
||||||
function scrollToBottom() {
|
|
||||||
if (messagesContainer.value) {
|
|
||||||
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function sendMessage() {
|
|
||||||
const query = inputText.value.trim()
|
|
||||||
if (!query || loading.value) return
|
|
||||||
|
|
||||||
inputText.value = ''
|
|
||||||
errorMsg.value = ''
|
|
||||||
messages.value.push({ role: 'user', content: query })
|
|
||||||
loading.value = true
|
|
||||||
|
|
||||||
await nextTick()
|
|
||||||
scrollToBottom()
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await $fetch<{
|
|
||||||
response: string
|
|
||||||
auteur: { slug: string; nom: string } | null
|
|
||||||
}>('/api/chatbot-pensees', {
|
|
||||||
method: 'POST',
|
|
||||||
body: {
|
|
||||||
query,
|
|
||||||
corpus: corpusMode.value,
|
|
||||||
auteur_slug: props.auteurContext ? slugify(props.auteurContext) : undefined,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
messages.value.push({
|
|
||||||
role: 'assistant',
|
|
||||||
content: res.response,
|
|
||||||
auteur: res.auteur ?? null,
|
|
||||||
})
|
|
||||||
} catch (e: any) {
|
|
||||||
const status = e?.statusCode ?? e?.status
|
|
||||||
if (status === 429) {
|
|
||||||
errorMsg.value = 'Limite de 20 questions par jour atteinte.'
|
|
||||||
} else if (status === 503) {
|
|
||||||
errorMsg.value = 'Le RAG est indisponible pour l\'instant — réessaie dans quelques minutes.'
|
|
||||||
} else if (status === 504) {
|
|
||||||
errorMsg.value = 'Le RAG met du temps à répondre — réessaie dans quelques secondes.'
|
|
||||||
} else {
|
|
||||||
errorMsg.value = 'Une erreur est survenue. Réessaie dans quelques instants.'
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
await nextTick()
|
|
||||||
scrollToBottom()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function slugify(str: string): string {
|
|
||||||
return str.toLowerCase()
|
|
||||||
.normalize('NFD').replace(/[̀-ͯ]/g, '')
|
|
||||||
.replace(/[^a-z0-9]+/g, '-')
|
|
||||||
.replace(/^-|-$/g, '')
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.chatbot-pensees {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
height: 100%;
|
|
||||||
min-height: 0;
|
|
||||||
background: var(--nav-bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Header */
|
|
||||||
.chatbot-header {
|
|
||||||
flex-shrink: 0;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 6px 12px;
|
|
||||||
background: var(--nav-surface);
|
|
||||||
border-bottom: 1px solid rgba(180, 170, 160, 0.22);
|
|
||||||
min-height: 36px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chatbot-header-left {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
min-width: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chatbot-icon {
|
|
||||||
flex-shrink: 0;
|
|
||||||
width: 22px;
|
|
||||||
height: 22px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--nav-primary);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
color: var(--nav-text-on-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.chatbot-title {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--nav-text);
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
max-width: 180px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chatbot-corpus-badge {
|
|
||||||
flex-shrink: 0;
|
|
||||||
font-size: 0.65rem;
|
|
||||||
font-weight: 600;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
color: var(--nav-text-muted);
|
|
||||||
background: var(--nav-bg-alt);
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: 1px 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chatbot-header-right {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chatbot-clear-btn {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 22px;
|
|
||||||
height: 22px;
|
|
||||||
border-radius: 4px;
|
|
||||||
background: transparent;
|
|
||||||
color: var(--nav-text-muted);
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 0.15s, color 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chatbot-clear-btn:hover {
|
|
||||||
background: var(--nav-bg-alt);
|
|
||||||
color: var(--nav-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.chatbot-corpus-select {
|
|
||||||
font-size: 0.7rem;
|
|
||||||
padding: 2px 4px;
|
|
||||||
border-radius: 4px;
|
|
||||||
border: 1px solid rgba(180, 170, 160, 0.3);
|
|
||||||
background: var(--nav-bg-alt);
|
|
||||||
color: var(--nav-text-muted);
|
|
||||||
cursor: pointer;
|
|
||||||
max-width: 110px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Messages */
|
|
||||||
.chatbot-messages {
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding: 10px 12px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
min-height: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chatbot-onboarding {
|
|
||||||
font-size: 0.8rem;
|
|
||||||
line-height: 1.55;
|
|
||||||
color: var(--nav-text-muted);
|
|
||||||
background: var(--nav-bg-alt);
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 10px 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chatbot-onboarding p { margin: 0 0 6px; }
|
|
||||||
.chatbot-onboarding p:last-child { margin-bottom: 0; }
|
|
||||||
.chatbot-onboarding strong { color: var(--nav-text); font-weight: 600; }
|
|
||||||
.chatbot-onboarding-hint { font-style: italic; font-size: 0.75rem; opacity: 0.75; }
|
|
||||||
|
|
||||||
/* Bulles */
|
|
||||||
.chatbot-bubble {
|
|
||||||
max-width: 88%;
|
|
||||||
font-size: 0.825rem;
|
|
||||||
line-height: 1.55;
|
|
||||||
border-radius: 10px;
|
|
||||||
padding: 8px 11px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chatbot-bubble.user {
|
|
||||||
align-self: flex-end;
|
|
||||||
background: var(--nav-primary);
|
|
||||||
color: var(--nav-text-on-primary);
|
|
||||||
border-radius: 10px 10px 3px 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chatbot-bubble.assistant {
|
|
||||||
align-self: flex-start;
|
|
||||||
background: var(--nav-surface);
|
|
||||||
border: 1px solid rgba(180, 170, 160, 0.22);
|
|
||||||
color: var(--nav-text);
|
|
||||||
border-radius: 10px 10px 10px 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Markdown dans les bulles */
|
|
||||||
:deep(.chatbot-md) { font-size: inherit; line-height: 1.55; }
|
|
||||||
:deep(.chatbot-md p) { margin: 0 0 0.35em; }
|
|
||||||
:deep(.chatbot-md p:last-child) { margin-bottom: 0; }
|
|
||||||
:deep(.chatbot-md strong) { font-weight: 700; }
|
|
||||||
:deep(.chatbot-md em) { font-style: italic; }
|
|
||||||
:deep(.chatbot-md ul) { margin: 0.25em 0 0.25em 1em; list-style: disc; padding: 0; }
|
|
||||||
:deep(.chatbot-md li) { margin-bottom: 0.1em; }
|
|
||||||
|
|
||||||
.chatbot-auteur-tag {
|
|
||||||
margin-top: 6px;
|
|
||||||
font-size: 0.7rem;
|
|
||||||
color: var(--nav-text-muted);
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Loading */
|
|
||||||
.chatbot-bubble.loading {
|
|
||||||
display: flex;
|
|
||||||
gap: 4px;
|
|
||||||
align-items: center;
|
|
||||||
padding: 10px 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dot {
|
|
||||||
width: 6px;
|
|
||||||
height: 6px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--nav-text-muted);
|
|
||||||
animation: blink 1.2s infinite ease-in-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dot:nth-child(2) { animation-delay: 0.2s; }
|
|
||||||
.dot:nth-child(3) { animation-delay: 0.4s; }
|
|
||||||
|
|
||||||
@keyframes blink {
|
|
||||||
0%, 80%, 100% { opacity: 0.25; }
|
|
||||||
40% { opacity: 1; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Erreur */
|
|
||||||
.chatbot-error {
|
|
||||||
align-self: flex-start;
|
|
||||||
font-size: 0.775rem;
|
|
||||||
color: #a85d3e;
|
|
||||||
background: rgba(168, 93, 62, 0.08);
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 7px 10px;
|
|
||||||
max-width: 90%;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Input */
|
|
||||||
.chatbot-input-row {
|
|
||||||
flex-shrink: 0;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
padding: 6px 10px;
|
|
||||||
border-top: 1px solid rgba(180, 170, 160, 0.22);
|
|
||||||
background: var(--nav-surface);
|
|
||||||
}
|
|
||||||
|
|
||||||
.chatbot-input {
|
|
||||||
flex: 1;
|
|
||||||
padding: 6px 10px;
|
|
||||||
border-radius: 8px;
|
|
||||||
border: 1px solid rgba(180, 170, 160, 0.3);
|
|
||||||
background: var(--nav-bg);
|
|
||||||
color: var(--nav-text);
|
|
||||||
font-family: var(--nav-font);
|
|
||||||
font-size: 0.8rem;
|
|
||||||
min-width: 0;
|
|
||||||
transition: border-color 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chatbot-input:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: var(--nav-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.chatbot-input:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chatbot-send-btn {
|
|
||||||
flex-shrink: 0;
|
|
||||||
width: 30px;
|
|
||||||
height: 30px;
|
|
||||||
border-radius: 7px;
|
|
||||||
border: none;
|
|
||||||
background: var(--nav-primary);
|
|
||||||
color: var(--nav-text-on-primary);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: opacity 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chatbot-send-btn:disabled {
|
|
||||||
opacity: 0.35;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chatbot-send-btn:not(:disabled):hover {
|
|
||||||
opacity: 0.85;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -52,8 +52,18 @@
|
|||||||
<div class="chatbot-body-inner" ref="messagesContainer">
|
<div class="chatbot-body-inner" ref="messagesContainer">
|
||||||
<!-- Onboarding -->
|
<!-- Onboarding -->
|
||||||
<div v-if="messages.length === 0" class="onboarding-bubble">
|
<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>Ce chatbot fonctionne sur un serveur européen souverain
|
||||||
<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>
|
(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>
|
||||||
|
<li>• Thématique : [juridique / technique / économique / ...]</li>
|
||||||
|
<li>• Lieu : [région ou ville]</li>
|
||||||
|
</ul>
|
||||||
|
<p class="example">Exemple : "Je suis salarié d'agence, litige avec mon
|
||||||
|
employeur, besoin conseil juridique droit du travail,
|
||||||
|
Île-de-France."</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Messages -->
|
<!-- Messages -->
|
||||||
@@ -62,7 +72,7 @@
|
|||||||
<div v-else class="assistant-bubble">
|
<div v-else class="assistant-bubble">
|
||||||
<p>{{ msg.content }}</p>
|
<p>{{ msg.content }}</p>
|
||||||
<div v-if="msg.fiches && msg.fiches.length > 0" class="fiches-list">
|
<div v-if="msg.fiches && msg.fiches.length > 0" class="fiches-list">
|
||||||
<p class="fiches-title">Fiches recommandees :</p>
|
<p class="fiches-title">Fiches recommandées :</p>
|
||||||
<a
|
<a
|
||||||
v-for="fiche in msg.fiches"
|
v-for="fiche in msg.fiches"
|
||||||
:key="fiche.id"
|
:key="fiche.id"
|
||||||
@@ -73,21 +83,6 @@
|
|||||||
<span v-if="fiche.explication" class="fiche-expl">{{ fiche.explication }}</span>
|
<span v-if="fiche.explication" class="fiche-expl">{{ fiche.explication }}</span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="msg.suggestedHashtags && msg.suggestedHashtags.length" style="margin-top: 8px;">
|
|
||||||
<p style="font-size: 0.7rem; color: var(--nav-text-muted); margin-bottom: 4px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em;">Filtrer par :</p>
|
|
||||||
<div style="display: flex; flex-wrap: wrap; gap: 4px;">
|
|
||||||
<span
|
|
||||||
v-for="tag in msg.suggestedHashtags"
|
|
||||||
:key="tag"
|
|
||||||
style="
|
|
||||||
padding: 2px 8px; border-radius: 9999px; font-size: 0.7rem; cursor: pointer;
|
|
||||||
background: var(--nav-bg-alt); color: var(--nav-text); border: 1px solid var(--nav-bg-alt);
|
|
||||||
transition: all 0.15s;
|
|
||||||
"
|
|
||||||
@click="emit('applyHashtag', tag)"
|
|
||||||
>{{ tag }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -137,12 +132,10 @@ interface ChatMessage {
|
|||||||
role: 'user' | 'assistant'
|
role: 'user' | 'assistant'
|
||||||
content: string
|
content: string
|
||||||
fiches?: FicheReco[]
|
fiches?: FicheReco[]
|
||||||
suggestedHashtags?: string[]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
'highlightOrgs': [ids: (number | string)[]]
|
'highlightOrgs': [ids: (number | string)[]]
|
||||||
'applyHashtag': [tag: string]
|
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const isExpanded = ref(false)
|
const isExpanded = ref(false)
|
||||||
@@ -152,37 +145,6 @@ const loading = ref(false)
|
|||||||
const errorMsg = ref('')
|
const errorMsg = ref('')
|
||||||
const messagesContainer = ref<HTMLElement | null>(null)
|
const messagesContainer = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
// Detection hashtags depuis la question posee
|
|
||||||
const HASHTAG_KEYWORDS: Record<string, string[]> = {
|
|
||||||
'#reemploi-structurel': ['reemploi', 'materiaux recuperes', 'deconstruction', 'reemploi structurel'],
|
|
||||||
'#reemploi-second-oeuvre': ['revetement', 'second oeuvre', 'reemploi'],
|
|
||||||
'#biosource-geosource': ['biosource', 'geosource', 'paille', 'terre', 'chanvre', 'lin', 'biosource'],
|
|
||||||
'#low-tech-experimentation': ['low-tech', 'low tech', 'technique simple', 'autonomie', 'lowtech'],
|
|
||||||
'#chantier-ecole': ['formation', 'chantier ecole', 'chantier-ecole', 'apprendre', 'auto-construction', 'autoconstruction'],
|
|
||||||
'#sobriete-energetique': ['sobriete', 'energie', 'renovation energetique', 'isolation', 'chauffage', 'economie energie'],
|
|
||||||
'#mal-logement-precarite': ['mal-logement', 'precarite', 'sans-abri', 'logement social', 'squat', 'mal logement'],
|
|
||||||
'#tiers-lieux-friches': ['friche', 'tiers-lieu', 'tiers lieu', 'espace intermediaire', 'temporaire', 'reconversion'],
|
|
||||||
'#accompagnement-cooperatif': ['cooperative', 'accompagnement', 'cooperation', 'collectif', 'mutualisation'],
|
|
||||||
'#transition-energetique-territoriale': ['territoire', 'transition', 'energetique', 'local', 'region', 'transition energetique'],
|
|
||||||
'#communs-fonciers': ['communs', 'foncier', 'anti-speculatif', 'community land trust', 'commun foncier'],
|
|
||||||
'#hack-juridique': ['juridique', 'montage', 'structure legale', 'sci', 'cooperative', 'statut'],
|
|
||||||
'#retrofit-strates': ['retrofit', 'renovation lourde', 'sur-isolation', 'rehaussement'],
|
|
||||||
'#phytoconstruction': ['plantes', 'vegetal', 'arbre', 'construction vivante', 'phyto'],
|
|
||||||
}
|
|
||||||
|
|
||||||
function detectHashtagsFromQuery(query: string): string[] {
|
|
||||||
const q = query.toLowerCase()
|
|
||||||
.normalize('NFD')
|
|
||||||
.replace(/[̀-ͯ]/g, '')
|
|
||||||
const detected: string[] = []
|
|
||||||
for (const [hashtag, keywords] of Object.entries(HASHTAG_KEYWORDS)) {
|
|
||||||
if (keywords.some(kw => q.includes(kw))) {
|
|
||||||
detected.push(hashtag)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return detected.slice(0, 3)
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleExpand() {
|
function toggleExpand() {
|
||||||
isExpanded.value = !isExpanded.value
|
isExpanded.value = !isExpanded.value
|
||||||
}
|
}
|
||||||
@@ -208,12 +170,10 @@ async function sendMessage() {
|
|||||||
body: { question },
|
body: { question },
|
||||||
})
|
})
|
||||||
|
|
||||||
const suggestedHashtags = detectHashtagsFromQuery(question)
|
|
||||||
const assistantMsg: ChatMessage = {
|
const assistantMsg: ChatMessage = {
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
content: res.reponse_texte,
|
content: res.reponse_texte,
|
||||||
fiches: res.fiches_recommandees || [],
|
fiches: res.fiches_recommandees || [],
|
||||||
suggestedHashtags: suggestedHashtags.length ? suggestedHashtags : undefined,
|
|
||||||
}
|
}
|
||||||
messages.value.push(assistantMsg)
|
messages.value.push(assistantMsg)
|
||||||
|
|
||||||
|
|||||||
@@ -1,208 +0,0 @@
|
|||||||
<template>
|
|
||||||
<Teleport to="body">
|
|
||||||
<transition name="backdrop">
|
|
||||||
<div
|
|
||||||
v-if="modelValue"
|
|
||||||
class="fixed inset-0 z-[1010]"
|
|
||||||
style="background: rgba(26,34,56,0.5);"
|
|
||||||
@click="emit('update:modelValue', false)"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
</transition>
|
|
||||||
|
|
||||||
<transition name="sheet">
|
|
||||||
<div
|
|
||||||
v-if="modelValue"
|
|
||||||
class="fixed inset-x-0 bottom-0 z-[1011] flex flex-col"
|
|
||||||
style="background: var(--nav-surface); height: 100dvh; max-height: 100dvh; box-shadow: 0 -4px 32px rgba(26,34,56,0.18);"
|
|
||||||
role="dialog"
|
|
||||||
aria-modal="true"
|
|
||||||
aria-label="Assistant Réseaux AEP"
|
|
||||||
>
|
|
||||||
<div class="flex justify-center pt-3 pb-1 shrink-0">
|
|
||||||
<div class="rounded-full" style="width: 36px; height: 4px; background: var(--nav-bg-alt);" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center justify-between px-4 py-3 shrink-0 border-b" style="border-color: var(--nav-bg-alt);">
|
|
||||||
<button
|
|
||||||
@click="emit('update:modelValue', false)"
|
|
||||||
class="flex items-center gap-2 text-sm font-medium transition-opacity hover:opacity-70"
|
|
||||||
style="color: var(--nav-text-muted);"
|
|
||||||
aria-label="Retour"
|
|
||||||
>
|
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
|
||||||
<polyline points="15 18 9 12 15 6"/>
|
|
||||||
</svg>
|
|
||||||
Retour
|
|
||||||
</button>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<div class="w-7 h-7 rounded-full flex items-center justify-center shrink-0" style="background: var(--nav-primary);">
|
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color: var(--nav-text-on-primary);">
|
|
||||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<span class="font-bold text-sm" style="color: var(--nav-text);">Réseaux AEP</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div ref="messagesContainer" class="flex-1 overflow-y-auto px-4 py-4 flex flex-col gap-3">
|
|
||||||
<div v-if="messages.length === 0" class="onboarding-bubble">
|
|
||||||
<p>Je connais les <strong>120 réseaux, collectifs et agences</strong> cartographiés dans AEP — ceux qui portent une vision écologique et politique de l'architecture.</p>
|
|
||||||
<p>Décris ta situation : tu cherches un collectif, une agence inspirante, un partenaire sur un projet en Occitanie, en transition énergétique ?</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<template v-for="(msg, i) in messages" :key="i">
|
|
||||||
<div v-if="msg.role === 'user'" class="user-bubble">{{ msg.content }}</div>
|
|
||||||
<div v-else class="assistant-bubble">
|
|
||||||
<div v-html="renderMd(msg.content)" />
|
|
||||||
<div v-if="msg.fiches && msg.fiches.length > 0" class="fiches-list" style="margin-top:12px;">
|
|
||||||
<p class="fiches-title">Structures recommandées :</p>
|
|
||||||
<a
|
|
||||||
v-for="fiche in msg.fiches"
|
|
||||||
:key="fiche.id"
|
|
||||||
:href="`/agences#${fiche.id}`"
|
|
||||||
class="fiche-card"
|
|
||||||
>
|
|
||||||
<span class="fiche-nom">{{ fiche.nom }}</span>
|
|
||||||
<span v-if="fiche.explication" class="fiche-expl">{{ fiche.explication }}</span>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<div v-if="loading" class="assistant-bubble loading-bubble">
|
|
||||||
<span class="dot" /><span class="dot" /><span class="dot" />
|
|
||||||
</div>
|
|
||||||
<div v-if="errorMsg" class="error-bubble">{{ errorMsg }}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="shrink-0 px-4 pt-3 border-t" style="border-color: var(--nav-bg-alt); padding-bottom: max(1rem, env(safe-area-inset-bottom));">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<input
|
|
||||||
v-model="inputText"
|
|
||||||
type="text"
|
|
||||||
:disabled="loading"
|
|
||||||
placeholder="Décris ta situation…"
|
|
||||||
class="flex-1 px-4 py-3 rounded-xl text-sm border"
|
|
||||||
style="border-color: var(--nav-bg-alt); background: var(--nav-bg); color: var(--nav-text); font-family: var(--nav-font); font-size: 16px;"
|
|
||||||
@keydown.enter.prevent="sendMessage"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
:disabled="loading || !inputText.trim()"
|
|
||||||
class="w-11 h-11 rounded-xl flex items-center justify-center shrink-0 transition-opacity"
|
|
||||||
style="background: var(--nav-primary);"
|
|
||||||
:style="{ opacity: (loading || !inputText.trim()) ? 0.4 : 1 }"
|
|
||||||
aria-label="Envoyer"
|
|
||||||
@click="sendMessage"
|
|
||||||
>
|
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="color: var(--nav-text-on-primary);">
|
|
||||||
<line x1="22" y1="2" x2="11" y2="13"/>
|
|
||||||
<polygon points="22 2 15 22 11 13 2 9 22 2"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</transition>
|
|
||||||
</Teleport>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { useMarkdown } from '~/composables/useMarkdown'
|
|
||||||
const { render: renderMd } = useMarkdown()
|
|
||||||
|
|
||||||
interface FicheReco { id: number | string; nom: string; explication?: string }
|
|
||||||
interface ChatMessage { role: 'user' | 'assistant'; content: string; fiches?: FicheReco[] }
|
|
||||||
|
|
||||||
const props = defineProps<{ modelValue: boolean }>()
|
|
||||||
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
|
|
||||||
|
|
||||||
const messages = ref<ChatMessage[]>([])
|
|
||||||
const inputText = ref('')
|
|
||||||
const loading = ref(false)
|
|
||||||
const errorMsg = ref('')
|
|
||||||
const messagesContainer = ref<HTMLElement | null>(null)
|
|
||||||
|
|
||||||
watch(() => props.modelValue, (open) => {
|
|
||||||
if (typeof document === 'undefined') return
|
|
||||||
document.body.style.overflow = open ? 'hidden' : ''
|
|
||||||
document.documentElement.style.overflow = open ? 'hidden' : ''
|
|
||||||
})
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
if (typeof document !== 'undefined') {
|
|
||||||
document.body.style.overflow = ''
|
|
||||||
document.documentElement.style.overflow = ''
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
async function sendMessage() {
|
|
||||||
const question = inputText.value.trim()
|
|
||||||
if (!question || loading.value) return
|
|
||||||
inputText.value = ''
|
|
||||||
errorMsg.value = ''
|
|
||||||
messages.value.push({ role: 'user', content: question })
|
|
||||||
loading.value = true
|
|
||||||
await nextTick()
|
|
||||||
if (messagesContainer.value) messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await $fetch<{ reponse_texte: string; fiches_recommandees: FicheReco[] }>(
|
|
||||||
'/api/chatbot-reseaux',
|
|
||||||
{ method: 'POST', body: { question } }
|
|
||||||
)
|
|
||||||
messages.value.push({ role: 'assistant', content: res.reponse_texte, fiches: res.fiches_recommandees || [] })
|
|
||||||
} catch (e: any) {
|
|
||||||
const s = e?.statusCode ?? e?.status
|
|
||||||
errorMsg.value = s === 429
|
|
||||||
? 'Limite de 20 questions par jour atteinte.'
|
|
||||||
: 'Une erreur est survenue. Réessaie dans quelques instants.'
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
await nextTick()
|
|
||||||
if (messagesContainer.value) messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.backdrop-enter-active, .backdrop-leave-active { transition: opacity 0.2s ease; }
|
|
||||||
.backdrop-enter-from, .backdrop-leave-to { opacity: 0; }
|
|
||||||
.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%); }
|
|
||||||
|
|
||||||
.onboarding-bubble {
|
|
||||||
background: var(--nav-bg); border: 1px solid var(--nav-bg-alt);
|
|
||||||
border-radius: 12px; padding: 16px;
|
|
||||||
font-size: 0.85rem; line-height: 1.65; color: var(--nav-text-muted);
|
|
||||||
}
|
|
||||||
.onboarding-bubble p { margin-bottom: 10px; }
|
|
||||||
.onboarding-bubble strong { font-weight: 700; color: var(--nav-text); }
|
|
||||||
|
|
||||||
.user-bubble {
|
|
||||||
align-self: flex-end; max-width: 80%;
|
|
||||||
background: var(--nav-primary); color: var(--nav-text-on-primary);
|
|
||||||
border-radius: 16px 16px 4px 16px; padding: 10px 14px;
|
|
||||||
font-size: 0.875rem; line-height: 1.5;
|
|
||||||
}
|
|
||||||
.assistant-bubble {
|
|
||||||
align-self: flex-start; max-width: 90%;
|
|
||||||
background: var(--nav-surface); border: 1px solid var(--nav-bg-alt);
|
|
||||||
border-radius: 16px 16px 16px 4px; padding: 12px 14px;
|
|
||||||
font-size: 0.875rem; line-height: 1.6; color: var(--nav-text);
|
|
||||||
}
|
|
||||||
.loading-bubble { display: flex; gap: 5px; align-items: center; }
|
|
||||||
.dot { width: 7px; height: 7px; border-radius: 50%; background: var(--nav-text-muted); animation: blink 1.2s infinite; }
|
|
||||||
.dot:nth-child(2) { animation-delay: 0.2s; }
|
|
||||||
.dot:nth-child(3) { animation-delay: 0.4s; }
|
|
||||||
@keyframes blink { 0%,80%,100% { opacity: 0.3; } 40% { opacity: 1; } }
|
|
||||||
|
|
||||||
.error-bubble { align-self: flex-start; max-width: 90%; color: #a85d3e; font-size: 0.8rem; padding: 8px 12px; border-radius: 8px; background: rgba(168,93,62,0.08); }
|
|
||||||
|
|
||||||
.fiches-list { display: flex; flex-direction: column; gap: 6px; }
|
|
||||||
.fiches-title { font-size: 0.75rem; font-weight: 600; color: var(--nav-text-muted); text-transform: uppercase; letter-spacing: 0.04em; margin-bottom: 4px; }
|
|
||||||
.fiche-card { display: block; background: var(--nav-bg); border: 1px solid var(--nav-bg-alt); border-radius: 8px; padding: 8px 12px; text-decoration: none; transition: background 0.15s; }
|
|
||||||
.fiche-card:hover { background: var(--nav-bg-alt); }
|
|
||||||
.fiche-nom { display: block; font-size: 0.875rem; font-weight: 600; color: var(--nav-text); }
|
|
||||||
.fiche-expl { display: block; font-size: 0.8rem; color: var(--nav-text-muted); margin-top: 2px; }
|
|
||||||
</style>
|
|
||||||
@@ -69,13 +69,18 @@
|
|||||||
<div ref="messagesContainer" class="flex-1 overflow-y-auto px-4 py-4 flex flex-col gap-3">
|
<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) -->
|
<!-- Message onboarding (avant la première question) -->
|
||||||
<div v-if="messages.length === 0" class="onboarding-bubble">
|
<div v-if="messages.length === 0" class="onboarding-bubble">
|
||||||
<p>Pour m'aider à te répondre efficacement, formule ta requête ainsi :</p>
|
<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>
|
<ul>
|
||||||
<li>• Besoin : [ce que tu cherches]</li>
|
<li>• Besoin : [ce que tu cherches]</li>
|
||||||
<li>• Thématique : [juridique / technique / économique / ...]</li>
|
<li>• Thématique : [juridique / technique / économique / ...]</li>
|
||||||
<li>• Lieu : [région ou ville]</li>
|
<li>• Lieu : [région ou ville]</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p class="example">Exemple : "Je suis salarié d'agence, litige avec mon employeur, besoin conseil juridique droit du travail, Île-de-France."</p>
|
<p class="example">Exemple : "Je suis salarié d'agence, litige avec mon
|
||||||
|
employeur, besoin conseil juridique droit du travail,
|
||||||
|
Île-de-France."</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Messages -->
|
<!-- Messages -->
|
||||||
@@ -87,7 +92,7 @@
|
|||||||
|
|
||||||
<!-- Message assistant -->
|
<!-- Message assistant -->
|
||||||
<div v-else class="assistant-bubble">
|
<div v-else class="assistant-bubble">
|
||||||
<div class="md-content" v-html="renderMd(msg.content)" />
|
<p>{{ msg.content }}</p>
|
||||||
|
|
||||||
<!-- Fiches recommandées -->
|
<!-- Fiches recommandées -->
|
||||||
<div v-if="msg.fiches && msg.fiches.length > 0" class="fiches-list">
|
<div v-if="msg.fiches && msg.fiches.length > 0" class="fiches-list">
|
||||||
@@ -159,9 +164,6 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useMarkdown } from '~/composables/useMarkdown'
|
|
||||||
const { render: renderMd } = useMarkdown()
|
|
||||||
|
|
||||||
interface FicheReco {
|
interface FicheReco {
|
||||||
id: number | string
|
id: number | string
|
||||||
nom: string
|
nom: string
|
||||||
@@ -318,17 +320,7 @@ function scrollToBottom() {
|
|||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
color: var(--nav-text);
|
color: var(--nav-text);
|
||||||
}
|
}
|
||||||
.assistant-bubble > p { margin: 0; }
|
.assistant-bubble p { margin: 0; }
|
||||||
|
|
||||||
/* Markdown rendu via v-html — :deep() perce le scoped */
|
|
||||||
:deep(.md-content) { font-size: inherit; line-height: 1.6; }
|
|
||||||
:deep(.md-content p) { margin: 0 0 0.4em; }
|
|
||||||
:deep(.md-content p:last-child) { margin-bottom: 0; }
|
|
||||||
:deep(.md-content strong) { font-weight: 700; }
|
|
||||||
:deep(.md-content em) { font-style: italic; }
|
|
||||||
:deep(.md-content ul) { margin: 0.3em 0 0.3em 1.1em; list-style: disc; padding: 0; }
|
|
||||||
:deep(.md-content li) { margin-bottom: 0.15em; }
|
|
||||||
:deep(.md-content a) { text-decoration: underline; opacity: 0.8; }
|
|
||||||
|
|
||||||
/* Fiches recommandées */
|
/* Fiches recommandées */
|
||||||
.fiches-list {
|
.fiches-list {
|
||||||
|
|||||||
@@ -1,177 +0,0 @@
|
|||||||
<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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Survol : uniquement sur pointeur capable de survol. Sur tactile, :hover
|
|
||||||
reste collé après un tap (chip « allumé » sans être actif). */
|
|
||||||
@media (hover: hover) {
|
|
||||||
.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;
|
|
||||||
}
|
|
||||||
@media (hover: hover) {
|
|
||||||
.chip--echelle:hover {
|
|
||||||
background: var(--primary-hover);
|
|
||||||
border-color: var(--primary-hover);
|
|
||||||
color: var(--on-primary);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/* Actif = fond PLEIN. --primary est translucide dans le thème AEP
|
|
||||||
(rgba(26,34,56,.6) : 4,16:1 sur le fond de page, sous AA) ; le couple
|
|
||||||
--nav-primary-solid / --nav-text-on-primary (≈14,6:1) reste lisible aussi
|
|
||||||
en sombre. Le survol d'un chip actif ne change pas de fond. */
|
|
||||||
.chip--echelle.chip--active {
|
|
||||||
background: var(--nav-primary-solid);
|
|
||||||
border-color: var(--nav-primary-solid);
|
|
||||||
color: var(--nav-text-on-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Fonction/hashtag : pleine bleu, inactif = bg-alt ────────────────────── */
|
|
||||||
.chip--fonction {
|
|
||||||
border-color: transparent;
|
|
||||||
background: var(--bg-alt);
|
|
||||||
color: var(--text);
|
|
||||||
}
|
|
||||||
@media (hover: hover) {
|
|
||||||
.chip--fonction:hover {
|
|
||||||
background: var(--primary-hover);
|
|
||||||
color: var(--on-primary);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.chip--fonction.chip--active {
|
|
||||||
background: var(--nav-primary-solid);
|
|
||||||
color: var(--nav-text-on-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Posture (préparée pour plus tard) : outline safran ──────────────────── */
|
|
||||||
.chip--posture {
|
|
||||||
border-color: var(--accent);
|
|
||||||
color: var(--text);
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
@media (hover: hover) {
|
|
||||||
.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>
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<span class="text-xs font-bold uppercase tracking-wide block mb-1.5" style="color: var(--nav-text-muted);">CRITÈRES RÉGÉ</span>
|
||||||
|
<div class="flex flex-wrap gap-1">
|
||||||
|
<button
|
||||||
|
v-for="critere in CRITERES"
|
||||||
|
:key="critere.id"
|
||||||
|
type="button"
|
||||||
|
class="px-2 py-0.5 rounded-full text-xs transition-all"
|
||||||
|
:style="modelValue.includes(critere.id)
|
||||||
|
? 'background: var(--nav-primary); color: var(--nav-text-on-primary); font-weight: 600;'
|
||||||
|
: 'background: var(--nav-bg-alt); color: var(--nav-text-muted);'"
|
||||||
|
@click="toggle(critere.id)"
|
||||||
|
>
|
||||||
|
{{ critere.label }}
|
||||||
|
<span v-if="counts && counts[critere.id] !== undefined" class="ml-1 opacity-60 text-xs">{{ counts[critere.id] }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { CRITERES } from '~/types/pratique'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: number[]
|
||||||
|
counts?: Record<number, number>
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: number[]]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
function toggle(id: number) {
|
||||||
|
if (props.modelValue.includes(id)) {
|
||||||
|
emit('update:modelValue', props.modelValue.filter(v => v !== id))
|
||||||
|
} else {
|
||||||
|
emit('update:modelValue', [...props.modelValue, id])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -1,16 +1,39 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
<p class="text-xs font-bold uppercase tracking-widest" style="color: var(--nav-text-muted);">Échelle</p>
|
<p class="text-xs font-bold uppercase tracking-widest" style="color: var(--nav-text-muted);">Échelle</p>
|
||||||
<ChipGroup aria-label="Filtrer par échelle">
|
<!-- Inline sur 1 ligne — même pattern que FonctionFilter -->
|
||||||
<Chip
|
<div class="flex flex-wrap gap-x-4 gap-y-1.5">
|
||||||
|
<label
|
||||||
v-for="option in ECHELLES"
|
v-for="option in ECHELLES"
|
||||||
:key="option"
|
:key="option"
|
||||||
:label="option"
|
class="flex items-center gap-1.5 cursor-pointer select-none transition-opacity"
|
||||||
variant="echelle"
|
>
|
||||||
:active="isSelected(option)"
|
<!-- Case carrée -->
|
||||||
@toggle="toggle(option)"
|
<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)"
|
||||||
/>
|
/>
|
||||||
</ChipGroup>
|
</label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -1,121 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -5,44 +5,41 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
const baseTiles = useBaseTiles()
|
|
||||||
import type { Map, Marker, DivIcon } from 'leaflet'
|
import type { Map, Marker, DivIcon } from 'leaflet'
|
||||||
import type { StructureV2 } from '~/types/structure-v2'
|
|
||||||
|
|
||||||
// Couleurs par famille (synchronisées avec v2-bifurcation.css)
|
interface Pratique {
|
||||||
const FAMILLE_COLORS: Record<number, string> = {
|
id: number
|
||||||
1: '#a85d3e',
|
nom: string
|
||||||
2: '#c4a472',
|
lat?: number | null
|
||||||
3: '#d4a017',
|
lng?: number | null
|
||||||
4: '#5a7a4a',
|
pays?: string
|
||||||
5: '#3d6a8c',
|
ville?: string
|
||||||
|
type?: string
|
||||||
|
score?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
structures: StructureV2[]
|
orgs: Pratique[]
|
||||||
selectedId?: string | null
|
selectedId?: number | null
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
'select-structure': [id: string]
|
'select-org': [id: number]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const mapContainer = ref<HTMLElement | null>(null)
|
const mapContainer = ref<HTMLElement | null>(null)
|
||||||
let mapInstance: Map | null = null
|
let mapInstance: Map | null = null
|
||||||
let clusterGroup: any = null
|
let clusterGroup: any = null
|
||||||
const markers = new Map<string, Marker>()
|
const markers = new Map<number, Marker>()
|
||||||
let tileLayerInstance: any = null
|
let tileLayerInstance: any = null
|
||||||
|
|
||||||
function getFamilleColor(famille: number): string {
|
function createPinIcon(score: number, isSelected = false): DivIcon {
|
||||||
return FAMILLE_COLORS[famille] ?? '#888888'
|
|
||||||
}
|
|
||||||
|
|
||||||
function createPinIcon(famille: number, isSelected = false): DivIcon {
|
|
||||||
const L = (window as any).L
|
const L = (window as any).L
|
||||||
const bg = getFamilleColor(famille)
|
// Couleur selon score (1-5) : du pale au vif
|
||||||
const size = isSelected ? 20 : 14
|
const bg = score >= 4 ? '#f5b342' : score >= 3 ? 'rgba(26,34,56,0.75)' : 'rgba(26,34,56,0.5)'
|
||||||
const border = isSelected ? 'white' : 'rgba(255,255,255,0.7)'
|
const border = isSelected ? '#f5b342' : '#ffffff'
|
||||||
const shadow = isSelected ? `0 0 0 4px ${bg}55` : 'none'
|
const size = isSelected ? 18 : 14
|
||||||
|
const shadow = isSelected ? '0 0 0 4px rgba(245,179,66,0.5)' : 'none'
|
||||||
|
|
||||||
return L.divIcon({
|
return L.divIcon({
|
||||||
className: '',
|
className: '',
|
||||||
@@ -72,32 +69,35 @@ async function initMap() {
|
|||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
await import('leaflet.markercluster/dist/MarkerCluster.Default.css')
|
await import('leaflet.markercluster/dist/MarkerCluster.Default.css')
|
||||||
|
|
||||||
// Installer L globalement AVANT le plugin
|
|
||||||
;(window as any).L = L
|
;(window as any).L = L
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
await import('leaflet.markercluster')
|
await import('leaflet.markercluster')
|
||||||
const MarkerClusterGroup = L.MarkerClusterGroup
|
const MarkerClusterGroup = L.MarkerClusterGroup
|
||||||
|
|
||||||
mapInstance = L.map(mapContainer.value, {
|
mapInstance = L.map(mapContainer.value, {
|
||||||
center: [46.6, 2.3],
|
center: [50.0, 10.0],
|
||||||
zoom: 5,
|
zoom: 4,
|
||||||
zoomControl: true,
|
zoomControl: true,
|
||||||
attributionControl: true,
|
attributionControl: true,
|
||||||
minZoom: 2,
|
maxBounds: [[30.0, -15.0], [72.0, 40.0]],
|
||||||
|
maxBoundsViscosity: 0.8,
|
||||||
|
minZoom: 3,
|
||||||
maxZoom: 18,
|
maxZoom: 18,
|
||||||
})
|
})
|
||||||
|
|
||||||
const isDark = typeof document !== 'undefined' && document.documentElement.classList.contains('dark')
|
const isDark = typeof document !== 'undefined' && document.documentElement.classList.contains('dark')
|
||||||
const tileUrl = baseTiles.url(isDark)
|
const tileUrl = isDark
|
||||||
|
? 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'
|
||||||
|
: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png'
|
||||||
|
|
||||||
tileLayerInstance = L.tileLayer(tileUrl, {
|
tileLayerInstance = L.tileLayer(tileUrl, {
|
||||||
attribution: baseTiles.attribution,
|
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors © <a href="https://carto.com/attributions">CARTO</a>',
|
||||||
maxZoom: 19,
|
maxZoom: 19,
|
||||||
})
|
})
|
||||||
tileLayerInstance.addTo(mapInstance!)
|
tileLayerInstance.addTo(mapInstance!)
|
||||||
|
|
||||||
clusterGroup = new MarkerClusterGroup({
|
clusterGroup = new MarkerClusterGroup({
|
||||||
disableClusteringAtZoom: 14,
|
disableClusteringAtZoom: 12,
|
||||||
maxClusterRadius: 50,
|
maxClusterRadius: 50,
|
||||||
showCoverageOnHover: false,
|
showCoverageOnHover: false,
|
||||||
iconCreateFunction: (cluster: any) => {
|
iconCreateFunction: (cluster: any) => {
|
||||||
@@ -131,53 +131,37 @@ function updateMarkers(L?: any) {
|
|||||||
clusterGroup.clearLayers()
|
clusterGroup.clearLayers()
|
||||||
markers.clear()
|
markers.clear()
|
||||||
|
|
||||||
const structuresWithCoords = props.structures.filter(
|
const orgsWithCoords = props.orgs.filter(
|
||||||
(s) => s.latitude != null && s.longitude != null
|
(o) => o.lat != null && o.lng != null
|
||||||
)
|
)
|
||||||
|
|
||||||
structuresWithCoords.forEach((structure) => {
|
orgsWithCoords.forEach((org) => {
|
||||||
const isSelected = structure.id === props.selectedId
|
const isSelected = org.id === props.selectedId
|
||||||
const icon = createPinIcon(structure.famille_principale, isSelected)
|
const icon = createPinIcon(org.score ?? 1, isSelected)
|
||||||
|
|
||||||
const marker = leaflet.marker([structure.latitude!, structure.longitude!], { icon })
|
const marker = leaflet.marker([org.lat!, org.lng!], { icon })
|
||||||
|
|
||||||
const hashtagsHtml = structure.hashtags.slice(0, 2)
|
|
||||||
.map(h => `<span style="font-size:10px;color:var(--nav-text-muted);">${h}</span>`)
|
|
||||||
.join(' ')
|
|
||||||
|
|
||||||
marker.bindPopup(`
|
marker.bindPopup(`
|
||||||
<div style="font-family: var(--nav-font); min-width: 190px; padding: 4px 0;">
|
<div style="font-family: var(--nav-font); min-width: 180px; padding: 4px 0;">
|
||||||
<div style="font-weight: 700; color: var(--nav-text); margin-bottom: 2px; font-size: 0.9rem;">${structure.nom}</div>
|
<div style="font-weight: 700; color: var(--nav-text); margin-bottom: 4px;">${org.nom}</div>
|
||||||
<div style="font-size: 11px; color: var(--nav-text-muted); margin-bottom: 4px;">${structure.type_principal} · ${structure.ville}, ${structure.pays}</div>
|
${org.pays ? `<div style="font-size: 11px; color: var(--nav-text-muted);">${org.pays}${org.ville ? ' · ' + org.ville : ''}</div>` : ''}
|
||||||
${hashtagsHtml ? `<div style="margin-bottom: 6px;">${hashtagsHtml}</div>` : ''}
|
${org.type ? `<div style="font-size: 11px; color: var(--nav-text-muted); margin-top: 2px;">${org.type}</div>` : ''}
|
||||||
<div style="font-size: 11px; color: var(--nav-text); line-height: 1.4; margin-bottom: 8px;">${structure.description_courte.slice(0, 100)}…</div>
|
<a href="/pratique/${org.id}" style="
|
||||||
<button onclick="document.dispatchEvent(new CustomEvent('nav-v2-select', {detail:'${structure.id}'}))" style="
|
display: inline-block; margin-top: 8px; font-size: 12px;
|
||||||
font-size: 12px;
|
color: var(--nav-primary-solid); text-decoration: underline;
|
||||||
color: var(--nav-primary-solid);
|
">Voir la fiche →</a>
|
||||||
text-decoration: underline;
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 0;
|
|
||||||
font-family: var(--nav-font);
|
|
||||||
">Voir la fiche →</button>
|
|
||||||
</div>
|
</div>
|
||||||
`, { maxWidth: 260 })
|
`, { maxWidth: 240 })
|
||||||
|
|
||||||
marker.on('click', () => emit('select-structure', structure.id))
|
marker.on('click', () => emit('select-org', org.id))
|
||||||
|
|
||||||
markers.set(structure.id, marker)
|
markers.set(org.id, marker)
|
||||||
clusterGroup.addLayer(marker)
|
clusterGroup.addLayer(marker)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ecouter l'event custom depuis les popups Leaflet
|
|
||||||
function onNavV2Select(e: CustomEvent) {
|
|
||||||
emit('select-structure', e.detail)
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.structures,
|
() => props.orgs,
|
||||||
() => updateMarkers(),
|
() => updateMarkers(),
|
||||||
{ deep: false }
|
{ deep: false }
|
||||||
)
|
)
|
||||||
@@ -191,16 +175,16 @@ watch(
|
|||||||
|
|
||||||
if (oldId != null) {
|
if (oldId != null) {
|
||||||
const oldMarker = markers.get(oldId)
|
const oldMarker = markers.get(oldId)
|
||||||
const oldStructure = props.structures.find(s => s.id === oldId)
|
const oldOrg = props.orgs.find(o => o.id === oldId)
|
||||||
if (oldMarker && oldStructure) {
|
if (oldMarker && oldOrg) {
|
||||||
oldMarker.setIcon(createPinIcon(oldStructure.famille_principale, false))
|
oldMarker.setIcon(createPinIcon(oldOrg.score ?? 1, false))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (newId != null) {
|
if (newId != null) {
|
||||||
const newMarker = markers.get(newId)
|
const newMarker = markers.get(newId)
|
||||||
const newStructure = props.structures.find(s => s.id === newId)
|
const newOrg = props.orgs.find(o => o.id === newId)
|
||||||
if (newMarker && newStructure) {
|
if (newMarker && newOrg) {
|
||||||
newMarker.setIcon(createPinIcon(newStructure.famille_principale, true))
|
newMarker.setIcon(createPinIcon(newOrg.score ?? 1, true))
|
||||||
const latLng = newMarker.getLatLng()
|
const latLng = newMarker.getLatLng()
|
||||||
mapInstance.panTo(latLng, { animate: true })
|
mapInstance.panTo(latLng, { animate: true })
|
||||||
}
|
}
|
||||||
@@ -212,7 +196,9 @@ function updateTileTheme(dark: boolean) {
|
|||||||
if (!mapInstance || !tileLayerInstance) return
|
if (!mapInstance || !tileLayerInstance) return
|
||||||
const L = (window as any).L
|
const L = (window as any).L
|
||||||
if (!L) return
|
if (!L) return
|
||||||
const url = baseTiles.url(dark)
|
const url = dark
|
||||||
|
? 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'
|
||||||
|
: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png'
|
||||||
tileLayerInstance.setUrl(url)
|
tileLayerInstance.setUrl(url)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -220,7 +206,6 @@ let themeObserver: MutationObserver | null = null
|
|||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
initMap()
|
initMap()
|
||||||
document.addEventListener('nav-v2-select', onNavV2Select as EventListener)
|
|
||||||
|
|
||||||
themeObserver = new MutationObserver(() => {
|
themeObserver = new MutationObserver(() => {
|
||||||
const dark = document.documentElement.classList.contains('dark')
|
const dark = document.documentElement.classList.contains('dark')
|
||||||
@@ -230,7 +215,6 @@ onMounted(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
document.removeEventListener('nav-v2-select', onNavV2Select as EventListener)
|
|
||||||
themeObserver?.disconnect()
|
themeObserver?.disconnect()
|
||||||
if (mapInstance) {
|
if (mapInstance) {
|
||||||
mapInstance.remove()
|
mapInstance.remove()
|
||||||
@@ -183,7 +183,6 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
const baseTiles = useBaseTiles()
|
|
||||||
import type { Org } from '~/types/org'
|
import type { Org } from '~/types/org'
|
||||||
|
|
||||||
const props = defineProps<{ org: Org }>()
|
const props = defineProps<{ org: Org }>()
|
||||||
@@ -284,7 +283,7 @@ onMounted(async () => {
|
|||||||
attributionControl: false,
|
attributionControl: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
L.tileLayer(baseTiles.url(document.documentElement.classList.contains('dark')), {
|
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||||
maxZoom: 18,
|
maxZoom: 18,
|
||||||
}).addTo(map)
|
}).addTo(map)
|
||||||
|
|
||||||
|
|||||||
@@ -1,284 +0,0 @@
|
|||||||
<template>
|
|
||||||
<Teleport to="body">
|
|
||||||
<!-- Backdrop -->
|
|
||||||
<Transition name="backdrop">
|
|
||||||
<div
|
|
||||||
v-if="modelValue && familleId != null"
|
|
||||||
class="fixed inset-0 z-[1400]"
|
|
||||||
style="background: rgba(26,34,56,0.55);"
|
|
||||||
@click="close"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
</Transition>
|
|
||||||
|
|
||||||
<!-- Modal -->
|
|
||||||
<Transition name="modal">
|
|
||||||
<div
|
|
||||||
v-if="modelValue && familleId != null"
|
|
||||||
class="fixed z-[1401] top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 flex flex-col"
|
|
||||||
style="
|
|
||||||
width: min(720px, 94vw);
|
|
||||||
max-height: 88vh;
|
|
||||||
background: var(--nav-bg);
|
|
||||||
border-radius: 16px;
|
|
||||||
box-shadow: 0 16px 64px rgba(26,34,56,0.28);
|
|
||||||
overflow: hidden;
|
|
||||||
"
|
|
||||||
role="dialog"
|
|
||||||
aria-modal="true"
|
|
||||||
:aria-label="familleLabel"
|
|
||||||
@keydown.esc="close"
|
|
||||||
>
|
|
||||||
<!-- Header : background couleur famille -->
|
|
||||||
<div
|
|
||||||
class="flex items-center justify-between px-5 py-4 shrink-0"
|
|
||||||
:style="`background: ${familleColor}; color: white;`"
|
|
||||||
>
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<div
|
|
||||||
class="w-3 h-3 rounded-full shrink-0"
|
|
||||||
style="background: white;"
|
|
||||||
/>
|
|
||||||
<h2 class="text-lg font-bold" style="color: white;">{{ familleLabel }}</h2>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
@click="close"
|
|
||||||
class="w-8 h-8 rounded-lg flex items-center justify-center transition-colors"
|
|
||||||
style="background: rgba(255,255,255,0.18); color: white;"
|
|
||||||
aria-label="Fermer"
|
|
||||||
>
|
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" aria-hidden="true">
|
|
||||||
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Body scrollable -->
|
|
||||||
<div class="flex-1 overflow-y-auto px-5 py-5">
|
|
||||||
<!-- Description longue editoriale -->
|
|
||||||
<p
|
|
||||||
class="text-sm leading-relaxed mb-5"
|
|
||||||
style="color: var(--nav-text); white-space: pre-wrap;"
|
|
||||||
>{{ familleDescription }}</p>
|
|
||||||
|
|
||||||
<!-- Separateur -->
|
|
||||||
<div style="height: 1px; background: var(--nav-bg-alt); margin-bottom: 16px;" />
|
|
||||||
|
|
||||||
<!-- Mode fusion : Principal+Secondaire melanges (peu de secondaires) -->
|
|
||||||
<template v-if="!showSplit">
|
|
||||||
<h3
|
|
||||||
class="text-xs font-bold uppercase tracking-wide mb-3"
|
|
||||||
style="color: var(--nav-text-muted);"
|
|
||||||
>{{ allStructures.length }} structure{{ allStructures.length > 1 ? 's' : '' }}</h3>
|
|
||||||
<ul style="list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 4px;">
|
|
||||||
<li
|
|
||||||
v-for="s in allStructures"
|
|
||||||
:key="s.id"
|
|
||||||
@click="selectStructure(s.id)"
|
|
||||||
class="structure-row"
|
|
||||||
style="
|
|
||||||
display: flex; align-items: center; gap: 10px;
|
|
||||||
padding: 8px 10px; border-radius: 6px;
|
|
||||||
cursor: pointer; transition: background 0.1s;
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
style="width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0;"
|
|
||||||
:style="`background: ${FAMILLE_COLORS[s.famille_principale] ?? '#888'};`"
|
|
||||||
:title="`Famille principale : ${FAMILLE_LABELS[s.famille_principale] ?? ''}`"
|
|
||||||
/>
|
|
||||||
<span class="text-sm font-medium" style="color: var(--nav-text);">{{ s.nom }}</span>
|
|
||||||
<span class="text-xs" style="color: var(--nav-text-muted);">{{ s.ville }}</span>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- Mode split : Principal / Secondaire separes -->
|
|
||||||
<template v-else>
|
|
||||||
<div v-if="principalStructures.length" class="mb-5">
|
|
||||||
<h3
|
|
||||||
class="text-xs font-bold uppercase tracking-wide mb-3"
|
|
||||||
style="color: var(--nav-text-muted);"
|
|
||||||
>Principal ({{ principalStructures.length }})</h3>
|
|
||||||
<ul style="list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 4px;">
|
|
||||||
<li
|
|
||||||
v-for="s in principalStructures"
|
|
||||||
:key="s.id"
|
|
||||||
@click="selectStructure(s.id)"
|
|
||||||
class="structure-row"
|
|
||||||
style="
|
|
||||||
display: flex; align-items: center; gap: 10px;
|
|
||||||
padding: 8px 10px; border-radius: 6px;
|
|
||||||
cursor: pointer; transition: background 0.1s;
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
style="width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0;"
|
|
||||||
:style="`background: ${FAMILLE_COLORS[s.famille_principale] ?? '#888'};`"
|
|
||||||
/>
|
|
||||||
<span class="text-sm font-medium" style="color: var(--nav-text);">{{ s.nom }}</span>
|
|
||||||
<span class="text-xs" style="color: var(--nav-text-muted);">{{ s.ville }}</span>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="secondaireStructures.length">
|
|
||||||
<h3
|
|
||||||
class="text-xs font-bold uppercase tracking-wide mb-3"
|
|
||||||
style="color: var(--nav-text-muted);"
|
|
||||||
>Secondaire ({{ secondaireStructures.length }})</h3>
|
|
||||||
<ul style="list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 4px;">
|
|
||||||
<li
|
|
||||||
v-for="s in secondaireStructures"
|
|
||||||
:key="s.id"
|
|
||||||
@click="selectStructure(s.id)"
|
|
||||||
class="structure-row"
|
|
||||||
style="
|
|
||||||
display: flex; align-items: center; gap: 10px;
|
|
||||||
padding: 8px 10px; border-radius: 6px;
|
|
||||||
cursor: pointer; transition: background 0.1s;
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
style="width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0;"
|
|
||||||
:style="`background: ${FAMILLE_COLORS[s.famille_principale] ?? '#888'};`"
|
|
||||||
:title="`Famille principale : ${FAMILLE_LABELS[s.famille_principale] ?? ''}`"
|
|
||||||
/>
|
|
||||||
<span class="text-sm font-medium" style="color: var(--nav-text);">{{ s.nom }}</span>
|
|
||||||
<span class="text-xs" style="color: var(--nav-text-muted);">{{ s.ville }}</span>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Footer -->
|
|
||||||
<div
|
|
||||||
class="px-5 py-3 shrink-0 text-xs"
|
|
||||||
style="border-top: 1px solid var(--nav-bg-alt); color: var(--nav-text-muted); background: var(--nav-surface);"
|
|
||||||
>
|
|
||||||
Click sur une structure pour ouvrir sa fiche
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Transition>
|
|
||||||
</Teleport>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import type { ReseauxBifurcationData, StructureV2 } from '~/types/structure-v2'
|
|
||||||
|
|
||||||
const FAMILLE_COLORS: Record<number, string> = {
|
|
||||||
1: '#a85d3e',
|
|
||||||
2: '#c4a472',
|
|
||||||
3: '#d4a017',
|
|
||||||
4: '#5a7a4a',
|
|
||||||
5: '#3d6a8c',
|
|
||||||
6: '#6b3fa0',
|
|
||||||
}
|
|
||||||
|
|
||||||
const FAMILLE_LABELS: Record<number, string> = {
|
|
||||||
1: 'Reemploi & filieres',
|
|
||||||
2: 'Frugalite & low-tech',
|
|
||||||
3: 'Architecture sociale',
|
|
||||||
4: 'Collectifs & AMO',
|
|
||||||
5: 'Urbanisme de transition',
|
|
||||||
6: 'Recherche-action',
|
|
||||||
}
|
|
||||||
|
|
||||||
const FAMILLE_DESCRIPTIONS: Record<number, string> = {
|
|
||||||
1: "Structures dont le geste premier est de travailler avec la matiere existante : deconstruction selective, plateformes de redistribution, filieres biosourcees et geosourcees.",
|
|
||||||
2: "Pratiques qui partent du principe qu'on peut faire mieux avec moins. Renovation profonde, materiaux locaux, sobriete choisie.",
|
|
||||||
3: "Structures dont le terrain premier est le mal-logement, la precarite, l'hospitalite. Architecture comme reponse a l'urgence sociale.",
|
|
||||||
4: "Structures qui accompagnent les projets collectifs : cooperatives d'habitat, ecovillages, accompagnement vers l'autogestion ou la renovation.",
|
|
||||||
5: "Demarches a l'echelle du territoire : villes en transition, PLU alternatifs, coalitions territoriales.",
|
|
||||||
6: "Recherche-action et production de contre-savoirs (Forensic Architecture, Rural Studio, PEROU, Centrala). Badge transversal aux familles.",
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
modelValue: boolean
|
|
||||||
familleId: number | null
|
|
||||||
data: ReseauxBifurcationData | null
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
'update:modelValue': [value: boolean]
|
|
||||||
'select-structure': [id: string]
|
|
||||||
}>()
|
|
||||||
|
|
||||||
function close() {
|
|
||||||
emit('update:modelValue', false)
|
|
||||||
}
|
|
||||||
|
|
||||||
function selectStructure(id: string) {
|
|
||||||
// Fermer d'abord pour eviter superposition de modales
|
|
||||||
emit('update:modelValue', false)
|
|
||||||
emit('select-structure', id)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fermeture Esc globale
|
|
||||||
onMounted(() => {
|
|
||||||
const handler = (e: KeyboardEvent) => {
|
|
||||||
if (e.key === 'Escape' && props.modelValue) close()
|
|
||||||
}
|
|
||||||
window.addEventListener('keydown', handler)
|
|
||||||
onUnmounted(() => window.removeEventListener('keydown', handler))
|
|
||||||
})
|
|
||||||
|
|
||||||
const familleColor = computed(() =>
|
|
||||||
FAMILLE_COLORS[props.familleId ?? 0] ?? '#888'
|
|
||||||
)
|
|
||||||
|
|
||||||
const familleLabel = computed(() =>
|
|
||||||
FAMILLE_LABELS[props.familleId ?? 0] ?? ''
|
|
||||||
)
|
|
||||||
|
|
||||||
const familleDescription = computed(() =>
|
|
||||||
FAMILLE_DESCRIPTIONS[props.familleId ?? 0] ?? ''
|
|
||||||
)
|
|
||||||
|
|
||||||
const principalStructures = computed<StructureV2[]>(() => {
|
|
||||||
if (!props.data || props.familleId == null) return []
|
|
||||||
return props.data.structures
|
|
||||||
.filter(s => s.famille_principale === props.familleId)
|
|
||||||
.sort((a, b) => a.nom.localeCompare(b.nom, 'fr'))
|
|
||||||
})
|
|
||||||
|
|
||||||
const secondaireStructures = computed<StructureV2[]>(() => {
|
|
||||||
if (!props.data || props.familleId == null) return []
|
|
||||||
return props.data.structures
|
|
||||||
.filter(s =>
|
|
||||||
s.famille_principale !== props.familleId
|
|
||||||
&& (s.familles_secondaires ?? []).includes(props.familleId as number)
|
|
||||||
)
|
|
||||||
.sort((a, b) => a.nom.localeCompare(b.nom, 'fr'))
|
|
||||||
})
|
|
||||||
|
|
||||||
const allStructures = computed<StructureV2[]>(() => {
|
|
||||||
return [...principalStructures.value, ...secondaireStructures.value]
|
|
||||||
})
|
|
||||||
|
|
||||||
// Heuristique : si > 3 secondaires, separer en sections distinctes
|
|
||||||
const showSplit = computed(() => secondaireStructures.value.length > 3)
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.structure-row:hover {
|
|
||||||
background: var(--nav-bg-alt);
|
|
||||||
}
|
|
||||||
|
|
||||||
.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%, -52%);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
.backdrop-enter-active, .backdrop-leave-active { transition: none; }
|
|
||||||
.modal-enter-active, .modal-leave-active { transition: none; }
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -15,9 +15,10 @@
|
|||||||
<Transition name="modal">
|
<Transition name="modal">
|
||||||
<div
|
<div
|
||||||
v-if="modelValue && orgId != null"
|
v-if="modelValue && orgId != null"
|
||||||
class="fiche-modal fixed z-[1501] left-1/2 -translate-x-1/2 flex flex-col"
|
class="fixed z-[1501] top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 flex flex-col"
|
||||||
style="
|
style="
|
||||||
width: min(768px, 92vw);
|
width: min(768px, 92vw);
|
||||||
|
max-height: 90vh;
|
||||||
background: var(--nav-bg);
|
background: var(--nav-bg);
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
box-shadow: 0 16px 64px rgba(26,34,56,0.28);
|
box-shadow: 0 16px 64px rgba(26,34,56,0.28);
|
||||||
@@ -143,21 +144,6 @@ function onCommentSubmitted() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
/* Modal positionnement : centré desktop, descendu sous le header sur mobile */
|
|
||||||
.fiche-modal {
|
|
||||||
top: 50%;
|
|
||||||
transform: translate(-50%, -50%);
|
|
||||||
max-height: 90vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 1023px) {
|
|
||||||
.fiche-modal {
|
|
||||||
top: 76px;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
max-height: calc(100dvh - 92px);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Backdrop */
|
/* Backdrop */
|
||||||
.backdrop-enter-active, .backdrop-leave-active { transition: opacity 0.2s ease; }
|
.backdrop-enter-active, .backdrop-leave-active { transition: opacity 0.2s ease; }
|
||||||
.backdrop-enter-from, .backdrop-leave-to { opacity: 0; }
|
.backdrop-enter-from, .backdrop-leave-to { opacity: 0; }
|
||||||
@@ -170,11 +156,6 @@ function onCommentSubmitted() {
|
|||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translate(-50%, -52%);
|
transform: translate(-50%, -52%);
|
||||||
}
|
}
|
||||||
@media (max-width: 1023px) {
|
|
||||||
.modal-enter-from, .modal-leave-to {
|
|
||||||
transform: translate(-50%, calc(-2% + 76px));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.backdrop-enter-active, .backdrop-leave-active { transition: none; }
|
.backdrop-enter-active, .backdrop-leave-active { transition: none; }
|
||||||
|
|||||||
@@ -1,360 +0,0 @@
|
|||||||
<template>
|
|
||||||
<Teleport to="body">
|
|
||||||
<!-- Backdrop -->
|
|
||||||
<Transition name="backdrop">
|
|
||||||
<div
|
|
||||||
v-if="modelValue && structureId != null"
|
|
||||||
class="fixed inset-0 z-[1500]"
|
|
||||||
style="background: rgba(26,34,56,0.55);"
|
|
||||||
@click="close"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
</Transition>
|
|
||||||
|
|
||||||
<!-- Modal -->
|
|
||||||
<Transition name="modal">
|
|
||||||
<div
|
|
||||||
v-if="modelValue && structureId != null && structure"
|
|
||||||
class="fiche-modal-v2 fixed z-[1501] left-1/2 -translate-x-1/2 flex flex-col"
|
|
||||||
style="
|
|
||||||
width: min(780px, 94vw);
|
|
||||||
background: var(--nav-bg);
|
|
||||||
border-radius: 16px;
|
|
||||||
box-shadow: 0 16px 64px rgba(26,34,56,0.28);
|
|
||||||
overflow: hidden;
|
|
||||||
"
|
|
||||||
role="dialog"
|
|
||||||
aria-modal="true"
|
|
||||||
:aria-label="structure?.nom ?? 'Fiche structure'"
|
|
||||||
@keydown.esc="close"
|
|
||||||
>
|
|
||||||
<!-- Header modal -->
|
|
||||||
<div
|
|
||||||
class="flex items-center justify-between px-5 py-3 shrink-0"
|
|
||||||
:style="`border-bottom: 3px solid ${familleColor}; background: var(--nav-surface);`"
|
|
||||||
>
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<!-- Pastille famille -->
|
|
||||||
<div
|
|
||||||
class="w-3 h-3 rounded-full shrink-0"
|
|
||||||
:style="`background: ${familleColor};`"
|
|
||||||
/>
|
|
||||||
<span class="text-sm font-semibold" style="color: var(--nav-text-muted);">
|
|
||||||
{{ familleLabel }}
|
|
||||||
</span>
|
|
||||||
<!-- Badges -->
|
|
||||||
<div class="flex gap-1.5">
|
|
||||||
<span
|
|
||||||
v-if="structure.badges.centre_ressources"
|
|
||||||
class="px-2 py-0.5 rounded-full text-xs font-medium"
|
|
||||||
style="background: #2d8a6b22; color: #2d8a6b;"
|
|
||||||
>Centre ressources</span>
|
|
||||||
<span
|
|
||||||
v-if="structure.badges.mouvement_manifeste"
|
|
||||||
class="px-2 py-0.5 rounded-full text-xs font-medium"
|
|
||||||
style="background: #c44a2f22; color: #c44a2f;"
|
|
||||||
>Manifeste</span>
|
|
||||||
<span
|
|
||||||
v-if="structure.badges.contre_pouvoir_spatial"
|
|
||||||
class="px-2 py-0.5 rounded-full text-xs font-medium"
|
|
||||||
style="background: #1a3a6b22; color: #1a3a6b;"
|
|
||||||
>Contre-pouvoir</span>
|
|
||||||
<span
|
|
||||||
v-if="structure.badges.f6_recherche_politique"
|
|
||||||
class="px-2 py-0.5 rounded-full text-xs font-medium"
|
|
||||||
style="background: #6b3fa022; color: #6b3fa0;"
|
|
||||||
>Recherche pol.</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<a
|
|
||||||
v-if="structure.url"
|
|
||||||
:href="structure.url"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
class="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-opacity hover:opacity-70"
|
|
||||||
style="background: var(--nav-bg-alt); color: var(--nav-text);"
|
|
||||||
>
|
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
||||||
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
|
|
||||||
<polyline points="15 3 21 3 21 9"/>
|
|
||||||
<line x1="10" y1="14" x2="21" y2="3"/>
|
|
||||||
</svg>
|
|
||||||
Site web
|
|
||||||
</a>
|
|
||||||
<button
|
|
||||||
@click="close"
|
|
||||||
class="w-8 h-8 rounded-lg flex items-center justify-center transition-colors"
|
|
||||||
style="background: var(--nav-bg-alt); color: var(--nav-text-muted);"
|
|
||||||
aria-label="Fermer"
|
|
||||||
>
|
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" aria-hidden="true">
|
|
||||||
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Contenu scrollable -->
|
|
||||||
<div class="flex-1 overflow-y-auto px-5 py-5">
|
|
||||||
<!-- Nom + meta -->
|
|
||||||
<div class="mb-4">
|
|
||||||
<h2 class="text-xl font-bold mb-1" style="color: var(--nav-text);">{{ structure.nom }}</h2>
|
|
||||||
<div class="flex flex-wrap gap-2 text-sm" style="color: var(--nav-text-muted);">
|
|
||||||
<span>{{ structure.type_principal }}</span>
|
|
||||||
<span>·</span>
|
|
||||||
<span>{{ structure.ville }}, {{ structure.pays }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Hashtags -->
|
|
||||||
<div v-if="structure.hashtags.length" class="flex flex-wrap gap-1.5 mb-4">
|
|
||||||
<span
|
|
||||||
v-for="tag in structure.hashtags"
|
|
||||||
:key="tag"
|
|
||||||
class="px-2 py-0.5 rounded-full text-xs"
|
|
||||||
style="background: var(--nav-bg-alt); color: var(--nav-text-muted);"
|
|
||||||
>{{ tag }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Description courte -->
|
|
||||||
<p class="text-sm leading-relaxed mb-4" style="color: var(--nav-text);">{{ structure.description_courte }}</p>
|
|
||||||
|
|
||||||
<!-- Description longue (expandable) -->
|
|
||||||
<div v-if="structure.description_longue" class="mb-4">
|
|
||||||
<div
|
|
||||||
class="text-sm leading-relaxed"
|
|
||||||
style="color: var(--nav-text); white-space: pre-wrap;"
|
|
||||||
:style="showFullDesc ? '' : 'max-height: 120px; overflow: hidden;'"
|
|
||||||
>{{ structure.description_longue }}</div>
|
|
||||||
<button
|
|
||||||
@click="showFullDesc = !showFullDesc"
|
|
||||||
class="mt-2 text-xs underline"
|
|
||||||
style="color: var(--nav-text-muted);"
|
|
||||||
>{{ showFullDesc ? 'Réduire' : 'Lire la suite…' }}</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Pensées rattachées -->
|
|
||||||
<div v-if="structure.pensees && structure.pensees.length" class="mb-4">
|
|
||||||
<h3 class="text-xs font-bold uppercase tracking-wide mb-2" style="color: var(--nav-text-muted);">Pensées rattachées</h3>
|
|
||||||
<div class="flex flex-wrap gap-1.5">
|
|
||||||
<span
|
|
||||||
v-for="pensee in structure.pensees"
|
|
||||||
:key="pensee.id"
|
|
||||||
class="px-2 py-0.5 rounded text-xs"
|
|
||||||
:style="pensee.confiance === 'ia_suggested'
|
|
||||||
? 'background: var(--nav-bg-alt); color: var(--nav-text-muted); border: 1px dashed var(--nav-bg-alt);'
|
|
||||||
: 'background: var(--nav-bg-alt); color: var(--nav-text);'"
|
|
||||||
:title="pensee.confiance === 'ia_suggested' ? 'IA suggéré' : ''"
|
|
||||||
>
|
|
||||||
{{ pensee.label }}<span v-if="pensee.confiance === 'ia_suggested'" class="ml-1 opacity-60">~</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Projets emblématiques -->
|
|
||||||
<div v-if="projetsStructure.length" class="mb-4">
|
|
||||||
<h3 class="text-xs font-bold uppercase tracking-wide mb-2" style="color: var(--nav-text-muted);">Projets emblématiques</h3>
|
|
||||||
<div class="space-y-2">
|
|
||||||
<div
|
|
||||||
v-for="projet in projetsStructure.slice(0, 5)"
|
|
||||||
:key="projet.id"
|
|
||||||
class="rounded-lg p-3"
|
|
||||||
style="background: var(--nav-surface); border: 1px solid var(--nav-bg-alt);"
|
|
||||||
>
|
|
||||||
<div class="flex items-start justify-between gap-2">
|
|
||||||
<span class="font-medium text-sm" style="color: var(--nav-text);">{{ projet.nom }}</span>
|
|
||||||
<span v-if="projet.annee" class="text-xs shrink-0" style="color: var(--nav-text-muted);">{{ projet.annee }}</span>
|
|
||||||
</div>
|
|
||||||
<div v-if="projet.lieu" class="text-xs mt-0.5" style="color: var(--nav-text-muted);">{{ projet.lieu }}</div>
|
|
||||||
<p class="text-xs mt-1 leading-relaxed" style="color: var(--nav-text-muted);">{{ projet.description.slice(0, 120) }}{{ projet.description.length > 120 ? '…' : '' }}</p>
|
|
||||||
<a
|
|
||||||
v-if="projet.url"
|
|
||||||
:href="projet.url"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
class="text-xs mt-1 inline-block"
|
|
||||||
style="color: var(--nav-text-muted); text-decoration: underline;"
|
|
||||||
>En savoir plus →</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Structures voisines (graphe) -->
|
|
||||||
<div v-if="structuresVoisines.length" class="mb-4">
|
|
||||||
<h3 class="text-xs font-bold uppercase tracking-wide mb-2" style="color: var(--nav-text-muted);">Structures liées</h3>
|
|
||||||
<div class="flex flex-wrap gap-2">
|
|
||||||
<button
|
|
||||||
v-for="voisine in structuresVoisines.slice(0, 6)"
|
|
||||||
:key="voisine.id"
|
|
||||||
class="px-2 py-1 rounded text-xs transition-colors hover:opacity-70"
|
|
||||||
style="background: var(--nav-bg-alt); color: var(--nav-text); border: 1px solid transparent;"
|
|
||||||
@click="emit('update:structureId', voisine.id)"
|
|
||||||
>{{ voisine.nom }}</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Sources -->
|
|
||||||
<div v-if="structure.sources && structure.sources.length" class="mb-4">
|
|
||||||
<h3 class="text-xs font-bold uppercase tracking-wide mb-2" style="color: var(--nav-text-muted);">Sources</h3>
|
|
||||||
<div class="space-y-1">
|
|
||||||
<div v-for="(source, i) in structure.sources" :key="i" class="flex items-center gap-2">
|
|
||||||
<span
|
|
||||||
class="px-1.5 py-0.5 rounded text-xs shrink-0"
|
|
||||||
style="background: var(--nav-bg-alt); color: var(--nav-text-muted);"
|
|
||||||
>{{ source.type }}</span>
|
|
||||||
<a
|
|
||||||
v-if="source.url"
|
|
||||||
:href="source.url"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
class="text-xs underline truncate"
|
|
||||||
style="color: var(--nav-text);"
|
|
||||||
>{{ source.titre }}</a>
|
|
||||||
<span v-else class="text-xs" style="color: var(--nav-text);">{{ source.titre }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- CTAs -->
|
|
||||||
<div class="flex gap-3 pt-2" style="border-top: 1px solid var(--nav-bg-alt);">
|
|
||||||
<a
|
|
||||||
href="/contribuer"
|
|
||||||
class="text-xs underline"
|
|
||||||
style="color: var(--nav-text-muted);"
|
|
||||||
>Signaler une erreur</a>
|
|
||||||
<a
|
|
||||||
href="/contribuer"
|
|
||||||
class="text-xs underline"
|
|
||||||
style="color: var(--nav-text-muted);"
|
|
||||||
>Réclamer cette fiche</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Transition>
|
|
||||||
</Teleport>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import type { ReseauxBifurcationData, StructureV2, ProjetEmblematique } from '~/types/structure-v2'
|
|
||||||
|
|
||||||
const FAMILLE_COLORS: Record<number, string> = {
|
|
||||||
1: '#a85d3e',
|
|
||||||
2: '#c4a472',
|
|
||||||
3: '#d4a017',
|
|
||||||
4: '#5a7a4a',
|
|
||||||
5: '#3d6a8c',
|
|
||||||
}
|
|
||||||
|
|
||||||
const FAMILLE_LABELS: Record<number, string> = {
|
|
||||||
1: 'Réemploi & filières',
|
|
||||||
2: 'Frugalité & low-tech',
|
|
||||||
3: 'Architecture sociale',
|
|
||||||
4: 'Collectifs & AMO',
|
|
||||||
5: 'Urbanisme de transition',
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
modelValue: boolean
|
|
||||||
structureId: string | null
|
|
||||||
data: ReseauxBifurcationData | null
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
'update:modelValue': [value: boolean]
|
|
||||||
'update:structureId': [id: string]
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const showFullDesc = ref(false)
|
|
||||||
|
|
||||||
function close() {
|
|
||||||
emit('update:modelValue', false)
|
|
||||||
showFullDesc.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fermeture Esc globale
|
|
||||||
onMounted(() => {
|
|
||||||
const handler = (e: KeyboardEvent) => {
|
|
||||||
if (e.key === 'Escape' && props.modelValue) close()
|
|
||||||
}
|
|
||||||
window.addEventListener('keydown', handler)
|
|
||||||
onUnmounted(() => window.removeEventListener('keydown', handler))
|
|
||||||
})
|
|
||||||
|
|
||||||
// Remettre showFullDesc a false a chaque changement de fiche
|
|
||||||
watch(() => props.structureId, () => {
|
|
||||||
showFullDesc.value = false
|
|
||||||
})
|
|
||||||
|
|
||||||
const structure = computed<StructureV2 | null>(() => {
|
|
||||||
if (!props.data || !props.structureId) return null
|
|
||||||
return props.data.structures.find(s => s.id === props.structureId) ?? null
|
|
||||||
})
|
|
||||||
|
|
||||||
const familleColor = computed(() =>
|
|
||||||
FAMILLE_COLORS[structure.value?.famille_principale ?? 1] ?? '#888'
|
|
||||||
)
|
|
||||||
|
|
||||||
const familleLabel = computed(() =>
|
|
||||||
FAMILLE_LABELS[structure.value?.famille_principale ?? 1] ?? ''
|
|
||||||
)
|
|
||||||
|
|
||||||
const projetsStructure = computed<ProjetEmblematique[]>(() => {
|
|
||||||
if (!props.data || !props.structureId) return []
|
|
||||||
return props.data.projets?.filter(p => p.structure_parent === props.structureId) ?? []
|
|
||||||
})
|
|
||||||
|
|
||||||
const structuresVoisines = computed<StructureV2[]>(() => {
|
|
||||||
if (!props.data || !props.structureId) return []
|
|
||||||
const edges = props.data.graphe?.edges ?? []
|
|
||||||
const voisineIds = edges
|
|
||||||
.filter(e => e.source === props.structureId || e.target === props.structureId)
|
|
||||||
.sort((a, b) => (b.score ?? 0) - (a.score ?? 0))
|
|
||||||
.map(e => e.source === props.structureId ? e.target : e.source)
|
|
||||||
.slice(0, 8)
|
|
||||||
|
|
||||||
return voisineIds
|
|
||||||
.map(id => props.data!.structures.find(s => s.id === id))
|
|
||||||
.filter(Boolean) as StructureV2[]
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
/* Backdrop */
|
|
||||||
.backdrop-enter-active, .backdrop-leave-active { transition: opacity 0.2s ease; }
|
|
||||||
.backdrop-enter-from, .backdrop-leave-to { opacity: 0; }
|
|
||||||
|
|
||||||
/* Modal positionnement : centré desktop, descendu sous le header sur mobile */
|
|
||||||
.fiche-modal-v2 {
|
|
||||||
top: 50%;
|
|
||||||
transform: translate(-50%, -50%);
|
|
||||||
max-height: 90vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 1023px) {
|
|
||||||
.fiche-modal-v2 {
|
|
||||||
top: 76px;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
max-height: calc(100dvh - 92px);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Modal */
|
|
||||||
.modal-enter-active, .modal-leave-active {
|
|
||||||
transition: opacity 0.2s ease, transform 0.2s ease;
|
|
||||||
}
|
|
||||||
.modal-enter-from, .modal-leave-to {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translate(-50%, -52%);
|
|
||||||
}
|
|
||||||
@media (max-width: 1023px) {
|
|
||||||
.modal-enter-from, .modal-leave-to {
|
|
||||||
transform: translate(-50%, calc(-2% + 76px));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
.backdrop-enter-active, .backdrop-leave-active { transition: none; }
|
|
||||||
.modal-enter-active, .modal-leave-active { transition: none; }
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,168 +0,0 @@
|
|||||||
<!--
|
|
||||||
FichesPanel — B3-M3
|
|
||||||
Panneau « toutes les fiches » : reçoit une liste déjà filtrée (recherche +
|
|
||||||
filtres en cours côté page), l'affiche en grille de FicheMiniCard, et
|
|
||||||
expose ses propres chips de filtrage branchées sur le MÊME état que la
|
|
||||||
page appelante (pas un second état — voir vérification du brief).
|
|
||||||
Ne crée pas de second champ de recherche : `query` sert uniquement à
|
|
||||||
nuancer le message d'état vide.
|
|
||||||
-->
|
|
||||||
<template>
|
|
||||||
<div class="fiches-panel">
|
|
||||||
<div class="fiches-panel__head">
|
|
||||||
<p class="fiches-panel__intention">{{ intention }}</p>
|
|
||||||
|
|
||||||
<div class="fiches-panel__count">
|
|
||||||
{{ fiches.length }} fiche{{ fiches.length > 1 ? 's' : '' }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ChipGroup v-if="hashtags.length" aria-label="Filtrer les fiches" class="fiches-panel__chips">
|
|
||||||
<Chip
|
|
||||||
v-for="tag in visibleHashtags"
|
|
||||||
:key="tag"
|
|
||||||
:label="tag"
|
|
||||||
variant="fonction"
|
|
||||||
:active="selected.includes(tag)"
|
|
||||||
@toggle="toggle(tag)"
|
|
||||||
/>
|
|
||||||
<Chip
|
|
||||||
v-if="isCollapsible"
|
|
||||||
:label="expanded ? 'Réduire' : `Voir les ${hiddenCount} autres`"
|
|
||||||
variant="echelle"
|
|
||||||
:active="expanded"
|
|
||||||
@toggle="expanded = !expanded"
|
|
||||||
/>
|
|
||||||
</ChipGroup>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="fiches-panel__body">
|
|
||||||
<p v-if="fiches.length === 0" class="fiches-panel__empty">
|
|
||||||
Aucun résultat<template v-if="query"> pour « {{ query }} »</template>.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div v-else class="fiches-panel__grid">
|
|
||||||
<FicheMiniCard
|
|
||||||
v-for="fiche in fiches"
|
|
||||||
:key="`${fiche.kind}-${fiche.id}`"
|
|
||||||
:fiche="fiche"
|
|
||||||
@open="emit('open', fiche)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import type { MiniFiche } from '~/composables/useFicheAdapter'
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
fiches: MiniFiche[]
|
|
||||||
hashtags: string[]
|
|
||||||
selected: string[]
|
|
||||||
query: string
|
|
||||||
intention: string
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
'update:selected': [value: string[]]
|
|
||||||
open: [fiche: MiniFiche]
|
|
||||||
}>()
|
|
||||||
|
|
||||||
// Hashtags repliés (B3-M4) — au-delà de COLLAPSE_THRESHOLD chips, n'affiche
|
|
||||||
// que les premières + un Chip-bouton « voir les N autres ». Les chips déjà
|
|
||||||
// actives restent toujours visibles même repliées. `/` (10 fonctions) ne
|
|
||||||
// dépasse jamais le seuil ; `/agences` (~60 hashtags) se replie. État
|
|
||||||
// déplié non persistant (ref locale, reset à chaque montage du composant).
|
|
||||||
const COLLAPSE_THRESHOLD = 14
|
|
||||||
const expanded = ref(false)
|
|
||||||
|
|
||||||
const isCollapsible = computed(() => props.hashtags.length > COLLAPSE_THRESHOLD)
|
|
||||||
|
|
||||||
const visibleHashtags = computed(() => {
|
|
||||||
if (expanded.value || !isCollapsible.value) return props.hashtags
|
|
||||||
const head = props.hashtags.slice(0, COLLAPSE_THRESHOLD)
|
|
||||||
const headSet = new Set(head)
|
|
||||||
const activeOutsideHead = props.hashtags.filter(
|
|
||||||
(tag) => props.selected.includes(tag) && !headSet.has(tag)
|
|
||||||
)
|
|
||||||
return [...head, ...activeOutsideHead]
|
|
||||||
})
|
|
||||||
|
|
||||||
const hiddenCount = computed(() => props.hashtags.length - visibleHashtags.value.length)
|
|
||||||
|
|
||||||
function toggle(tag: string) {
|
|
||||||
if (props.selected.includes(tag)) {
|
|
||||||
emit('update:selected', props.selected.filter((t) => t !== tag))
|
|
||||||
} else {
|
|
||||||
emit('update:selected', [...props.selected, tag])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.fiches-panel {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
height: 100%;
|
|
||||||
min-height: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fiches-panel__head {
|
|
||||||
flex-shrink: 0;
|
|
||||||
padding: var(--space-sm) var(--space-md);
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
background: var(--surface);
|
|
||||||
}
|
|
||||||
|
|
||||||
.fiches-panel__intention {
|
|
||||||
margin: 0 0 var(--space-xs);
|
|
||||||
font-size: var(--fs-sm);
|
|
||||||
line-height: var(--lh-base);
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.fiches-panel__count {
|
|
||||||
margin: 0 0 var(--space-xs);
|
|
||||||
font-size: var(--fs-xs);
|
|
||||||
font-weight: var(--fw-semibold);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.02em;
|
|
||||||
color: var(--text-subtle);
|
|
||||||
}
|
|
||||||
|
|
||||||
.fiches-panel__chips {
|
|
||||||
margin-top: var(--space-2xs);
|
|
||||||
}
|
|
||||||
|
|
||||||
.fiches-panel__body {
|
|
||||||
flex: 1;
|
|
||||||
min-height: 0;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding: var(--space-md);
|
|
||||||
}
|
|
||||||
|
|
||||||
.fiches-panel__empty {
|
|
||||||
margin: var(--space-xl) 0;
|
|
||||||
text-align: center;
|
|
||||||
font-size: var(--fs-sm);
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.fiches-panel__grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 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>
|
|
||||||
@@ -1,17 +1,32 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
<p class="text-xs font-bold uppercase tracking-widest" style="color: var(--nav-text-muted);">Fonction</p>
|
<p class="text-xs font-bold uppercase tracking-widest" style="color: var(--nav-text-muted);">Fonction</p>
|
||||||
<ChipGroup aria-label="Filtrer par fonction">
|
<div class="space-y-1">
|
||||||
<Chip
|
<button
|
||||||
v-for="fn in FONCTIONS"
|
v-for="fn in FONCTIONS"
|
||||||
:key="fn"
|
:key="fn"
|
||||||
:label="fn"
|
@click="toggle(fn)"
|
||||||
variant="fonction"
|
:aria-pressed="modelValue.includes(fn)"
|
||||||
:active="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"
|
||||||
:count="modelValue.includes(fn) ? (modelValue.indexOf(fn) + 1) : (counts[fn] ?? 0)"
|
:style="modelValue.includes(fn) ? 'background: rgba(26,34,56,0.06);' : ''"
|
||||||
@toggle="toggle(fn)"
|
>
|
||||||
/>
|
<!-- Case : affiche le rang de priorité si actif, sinon le nombre d'orgs -->
|
||||||
</ChipGroup>
|
<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>
|
||||||
<p v-if="modelValue.length" class="text-xs pt-0.5" style="color: var(--nav-text-muted);">
|
<p v-if="modelValue.length" class="text-xs pt-0.5" style="color: var(--nav-text-muted);">
|
||||||
{{ modelValue.length }} actif{{ modelValue.length > 1 ? 's' : '' }}
|
{{ modelValue.length }} actif{{ modelValue.length > 1 ? 's' : '' }}
|
||||||
<button @click="emit('update:modelValue', [])" class="ml-2 underline hover:opacity-70">Effacer</button>
|
<button @click="emit('update:modelValue', [])" class="ml-2 underline hover:opacity-70">Effacer</button>
|
||||||
|
|||||||
@@ -1,184 +0,0 @@
|
|||||||
<template>
|
|
||||||
<form @submit.prevent="submit" class="proposer-form" novalidate>
|
|
||||||
<div class="field-group" :class="{ 'field-error': errors.nom }">
|
|
||||||
<label for="eco-nom">Nom de l'organisation <span class="required">*</span></label>
|
|
||||||
<input id="eco-nom" v-model="form.nom" type="text" placeholder="Ex : UNSFA, Maison de l'Architecture..." autocomplete="organization" @blur="validateField('nom')" />
|
|
||||||
<span v-if="errors.nom" class="error-msg" role="alert">{{ errors.nom }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field-group" :class="{ 'field-error': errors.url }">
|
|
||||||
<label for="eco-url">Site web <span class="label-hint">(optionnel — recommandé pour l'enrichissement IA)</span></label>
|
|
||||||
<input id="eco-url" v-model="form.url" type="url" placeholder="https://..." @blur="validateField('url')" />
|
|
||||||
<span v-if="errors.url" class="error-msg" role="alert">{{ errors.url }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field-group" :class="{ 'field-error': errors.description_user }">
|
|
||||||
<label for="eco-desc">Description courte <span class="required">*</span> <span class="label-hint">(50 à 500 caractères)</span></label>
|
|
||||||
<textarea id="eco-desc" v-model="form.description_user" rows="4" placeholder="Présente l'organisation en quelques mots : ses missions, son public, ce qu'elle apporte..." @blur="validateField('description_user')" />
|
|
||||||
<div class="field-meta">
|
|
||||||
<span v-if="errors.description_user" class="error-msg" role="alert">{{ errors.description_user }}</span>
|
|
||||||
<span v-else class="char-count" :class="{ 'char-warn': form.description_user.length > 450 }">{{ form.description_user.length }}/500</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field-group" :class="{ 'field-error': errors.echelle }">
|
|
||||||
<fieldset>
|
|
||||||
<legend>Échelle <span class="required">*</span> <span class="label-hint">(une seule)</span></legend>
|
|
||||||
<div class="radio-group">
|
|
||||||
<label v-for="opt in ECHELLES" :key="opt" class="radio-label" :class="{ active: form.echelle === opt }">
|
|
||||||
<input type="radio" :value="opt" v-model="form.echelle" name="eco-echelle" @change="validateField('echelle')" />{{ opt }}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</fieldset>
|
|
||||||
<span v-if="errors.echelle" class="error-msg" role="alert">{{ errors.echelle }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field-group" :class="{ 'field-error': errors.fonctions }">
|
|
||||||
<fieldset>
|
|
||||||
<legend>Fonctions <span class="required">*</span> <span class="label-hint">(1 à 5 — l'ordre de clic = priorité)</span></legend>
|
|
||||||
<div class="checkbox-grid">
|
|
||||||
<label v-for="fn in FONCTIONS" :key="fn" class="checkbox-label" :class="{ active: form.fonctions.includes(fn), disabled: !form.fonctions.includes(fn) && form.fonctions.length >= 5 }">
|
|
||||||
<input type="checkbox" :value="fn" :checked="form.fonctions.includes(fn)" :disabled="!form.fonctions.includes(fn) && form.fonctions.length >= 5" @change="toggleFonction(fn)" />
|
|
||||||
<span class="fn-order" v-if="form.fonctions.includes(fn)">{{ form.fonctions.indexOf(fn) + 1 }}</span>{{ fn }}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</fieldset>
|
|
||||||
<span v-if="errors.fonctions" class="error-msg" role="alert">{{ errors.fonctions }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field-group" :class="{ 'field-error': errors.territoire }">
|
|
||||||
<fieldset>
|
|
||||||
<legend>Territoire <span class="required">*</span></legend>
|
|
||||||
<div class="radio-group">
|
|
||||||
<label v-for="t in TERRITOIRES" :key="t" class="radio-label" :class="{ active: form.territoire === t }">
|
|
||||||
<input type="radio" :value="t" v-model="form.territoire" name="eco-territoire" @change="validateField('territoire')" />{{ t }}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</fieldset>
|
|
||||||
<span v-if="errors.territoire" class="error-msg" role="alert">{{ errors.territoire }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field-group" :class="{ 'field-error': errors.localisation_ville }">
|
|
||||||
<label for="eco-ville">Ville principale <span class="label-hint">(optionnel — pour la géolocalisation sur la carte)</span></label>
|
|
||||||
<input id="eco-ville" v-model="form.localisation_ville" type="text" placeholder="Ex : Paris, Lyon, Bordeaux..." />
|
|
||||||
<span v-if="errors.localisation_ville" class="error-msg" role="alert">{{ errors.localisation_ville }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field-group" :class="{ 'field-error': errors.submitted_by_email }">
|
|
||||||
<label for="eco-email">Ton email <span class="label-hint">(optionnel — pour le suivi de modération)</span></label>
|
|
||||||
<input id="eco-email" v-model="form.submitted_by_email" type="email" placeholder="ton@email.fr" autocomplete="email" @blur="validateField('submitted_by_email')" />
|
|
||||||
<span v-if="errors.submitted_by_email" class="error-msg" role="alert">{{ errors.submitted_by_email }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="serverError" class="server-error" role="alert"><strong>Erreur :</strong> {{ serverError }}</div>
|
|
||||||
|
|
||||||
<div class="form-actions">
|
|
||||||
<button type="submit" class="btn-primary" :disabled="submitting">{{ submitting ? 'Envoi en cours...' : 'Proposer la fiche →' }}</button>
|
|
||||||
</div>
|
|
||||||
<p class="form-note">Ta fiche sera examinée par l'équipe avant publication.</p>
|
|
||||||
</form>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { z } from 'zod'
|
|
||||||
|
|
||||||
const ECHELLES = ['National', 'Régional', 'Local'] as const
|
|
||||||
const TERRITOIRES = ['Métropole', 'Guadeloupe', 'Martinique', 'Guyane', 'La Réunion', 'Mayotte'] as const
|
|
||||||
const FONCTIONS = ['Juridique', 'Technique', 'Économique', 'Administratif', 'Chantier', 'Comptabilité', 'Développement', 'Formation', 'Gestion d\'agence', 'Santé mentale'] as const
|
|
||||||
|
|
||||||
const Schema = z.object({
|
|
||||||
nom: z.string().min(3, 'Minimum 3 caractères').max(150, 'Maximum 150 caractères').trim(),
|
|
||||||
url: z.string().url('URL invalide (commencer par https://)').optional().or(z.literal('')),
|
|
||||||
description_user: z.string().min(50, 'Minimum 50 caractères').max(500, 'Maximum 500 caractères').trim(),
|
|
||||||
echelle: z.enum(ECHELLES, { errorMap: () => ({ message: 'Sélectionne une échelle' }) }),
|
|
||||||
fonctions: z.array(z.string()).min(1, 'Sélectionne au moins une fonction').max(5, 'Maximum 5 fonctions'),
|
|
||||||
territoire: z.enum(TERRITOIRES, { errorMap: () => ({ message: 'Sélectionne un territoire' }) }),
|
|
||||||
localisation_ville: z.string().max(100).optional(),
|
|
||||||
submitted_by_email: z.string().email('Email invalide').optional().or(z.literal('')),
|
|
||||||
})
|
|
||||||
|
|
||||||
const emit = defineEmits<{ success: [] }>()
|
|
||||||
|
|
||||||
const form = reactive({
|
|
||||||
nom: '', url: '', description_user: '', echelle: '' as string,
|
|
||||||
fonctions: [] as string[], territoire: '' as string,
|
|
||||||
localisation_ville: '', submitted_by_email: '',
|
|
||||||
})
|
|
||||||
const errors = reactive<Record<string, string>>({})
|
|
||||||
const submitting = ref(false)
|
|
||||||
const serverError = ref('')
|
|
||||||
|
|
||||||
function validateField(field: string) {
|
|
||||||
const partial = Schema.partial()
|
|
||||||
const result = partial.safeParse({ [field]: (form as any)[field] })
|
|
||||||
if (!result.success) {
|
|
||||||
const fieldErrors = result.error.flatten().fieldErrors
|
|
||||||
errors[field] = fieldErrors[field]?.[0] ?? ''
|
|
||||||
} else {
|
|
||||||
delete errors[field]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function validateAll(): boolean {
|
|
||||||
const result = Schema.safeParse(form)
|
|
||||||
if (!result.success) {
|
|
||||||
const flat = result.error.flatten().fieldErrors
|
|
||||||
Object.assign(errors, Object.fromEntries(Object.entries(flat).map(([k, v]) => [k, v?.[0] ?? ''])))
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
Object.keys(errors).forEach(k => delete errors[k])
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleFonction(fn: string) {
|
|
||||||
const idx = form.fonctions.indexOf(fn)
|
|
||||||
if (idx >= 0) form.fonctions.splice(idx, 1)
|
|
||||||
else if (form.fonctions.length < 5) form.fonctions.push(fn)
|
|
||||||
validateField('fonctions')
|
|
||||||
}
|
|
||||||
|
|
||||||
async function submit() {
|
|
||||||
serverError.value = ''
|
|
||||||
if (!validateAll()) {
|
|
||||||
await nextTick()
|
|
||||||
const firstError = document.querySelector('.field-error')
|
|
||||||
firstError?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
submitting.value = true
|
|
||||||
try {
|
|
||||||
await $fetch('/api/submit', {
|
|
||||||
method: 'POST',
|
|
||||||
body: {
|
|
||||||
submission_type: 'ecosysteme',
|
|
||||||
nom: form.nom,
|
|
||||||
url: form.url || undefined,
|
|
||||||
description_user: form.description_user,
|
|
||||||
echelle: form.echelle,
|
|
||||||
fonctions: form.fonctions,
|
|
||||||
territoire: form.territoire,
|
|
||||||
localisation_ville: form.localisation_ville || undefined,
|
|
||||||
submitted_by_email: form.submitted_by_email || undefined,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
emit('success')
|
|
||||||
} catch (e: any) {
|
|
||||||
const status = e?.status ?? e?.statusCode
|
|
||||||
if (status === 429) {
|
|
||||||
serverError.value = 'Tu as déjà soumis 3 fiches aujourd\'hui. Réessaie demain.'
|
|
||||||
} else if (status === 422 && e?.data) {
|
|
||||||
const fieldErrors = e.data
|
|
||||||
Object.entries(fieldErrors).forEach(([k, v]) => { errors[k] = Array.isArray(v) ? v[0] : String(v) })
|
|
||||||
serverError.value = 'Certains champs sont invalides — vérifie les erreurs ci-dessus.'
|
|
||||||
} else {
|
|
||||||
serverError.value = 'Une erreur s\'est produite. Réessaie dans quelques instants.'
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
submitting.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
@import './form-styles.css';
|
|
||||||
</style>
|
|
||||||
@@ -1,134 +0,0 @@
|
|||||||
<template>
|
|
||||||
<form @submit.prevent="submit" class="proposer-form" novalidate>
|
|
||||||
<div class="field-group" :class="{ 'field-error': errors.nom }">
|
|
||||||
<label for="job-nom">Nom de la plateforme / job board <span class="required">*</span></label>
|
|
||||||
<input id="job-nom" v-model="form.nom" type="text" placeholder="Ex : Houzz, Architoo, BOAMP..." @blur="validateField('nom')" />
|
|
||||||
<span v-if="errors.nom" class="error-msg" role="alert">{{ errors.nom }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field-group">
|
|
||||||
<label for="job-type">Type de plateforme <span class="label-hint">(optionnel)</span></label>
|
|
||||||
<div class="radio-group">
|
|
||||||
<label v-for="t in TYPES" :key="t" class="radio-label" :class="{ active: form.type_plateforme === t }">
|
|
||||||
<input type="radio" :value="t" v-model="form.type_plateforme" name="job-type" />{{ LABELS[t] }}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field-group" :class="{ 'field-error': errors.url }">
|
|
||||||
<label for="job-url">Site web <span class="required">*</span></label>
|
|
||||||
<input id="job-url" v-model="form.url" type="url" placeholder="https://..." @blur="validateField('url')" />
|
|
||||||
<span v-if="errors.url" class="error-msg" role="alert">{{ errors.url }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field-group" :class="{ 'field-error': errors.description_user }">
|
|
||||||
<label for="job-desc">Description courte <span class="required">*</span> <span class="label-hint">(50 à 500 caractères)</span></label>
|
|
||||||
<textarea id="job-desc" v-model="form.description_user" rows="4" placeholder="Décris la plateforme : qui la gère, quel type de missions, quel modèle économique..." @blur="validateField('description_user')" />
|
|
||||||
<div class="field-meta">
|
|
||||||
<span v-if="errors.description_user" class="error-msg" role="alert">{{ errors.description_user }}</span>
|
|
||||||
<span v-else class="char-count" :class="{ 'char-warn': form.description_user.length > 450 }">{{ form.description_user.length }}/500</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field-group" :class="{ 'field-error': errors.submitted_by_email }">
|
|
||||||
<label for="job-email">Ton email <span class="label-hint">(optionnel — pour le suivi)</span></label>
|
|
||||||
<input id="job-email" v-model="form.submitted_by_email" type="email" placeholder="ton@email.fr" autocomplete="email" @blur="validateField('submitted_by_email')" />
|
|
||||||
<span v-if="errors.submitted_by_email" class="error-msg" role="alert">{{ errors.submitted_by_email }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="serverError" class="server-error" role="alert"><strong>Erreur :</strong> {{ serverError }}</div>
|
|
||||||
|
|
||||||
<div class="form-actions">
|
|
||||||
<button type="submit" class="btn-primary" :disabled="submitting">{{ submitting ? 'Envoi en cours...' : 'Proposer →' }}</button>
|
|
||||||
</div>
|
|
||||||
<p class="form-note">Ta proposition sera examinée par l'équipe avant publication.</p>
|
|
||||||
</form>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { z } from 'zod'
|
|
||||||
|
|
||||||
const TYPES = ['mise-en-relation', 'appel-offre-public', 'communaute-pro'] as const
|
|
||||||
const LABELS: Record<string, string> = { 'mise-en-relation': 'Mise en relation (B2C)', 'appel-offre-public': 'Appels d\'offres publics', 'communaute-pro': 'Communauté pro' }
|
|
||||||
|
|
||||||
const Schema = z.object({
|
|
||||||
nom: z.string().min(3, 'Minimum 3 caractères').max(150, 'Maximum 150 caractères').trim(),
|
|
||||||
url: z.string().url('URL invalide (commencer par https://)'),
|
|
||||||
description_user: z.string().min(50, 'Minimum 50 caractères').max(500, 'Maximum 500 caractères').trim(),
|
|
||||||
type_plateforme: z.string().optional(),
|
|
||||||
submitted_by_email: z.string().email('Email invalide').optional().or(z.literal('')),
|
|
||||||
})
|
|
||||||
|
|
||||||
const emit = defineEmits<{ success: [] }>()
|
|
||||||
|
|
||||||
const form = reactive({
|
|
||||||
nom: '', url: '', description_user: '', type_plateforme: '', submitted_by_email: '',
|
|
||||||
})
|
|
||||||
const errors = reactive<Record<string, string>>({})
|
|
||||||
const submitting = ref(false)
|
|
||||||
const serverError = ref('')
|
|
||||||
|
|
||||||
function validateField(field: string) {
|
|
||||||
const partial = Schema.partial()
|
|
||||||
const result = partial.safeParse({ [field]: (form as any)[field] })
|
|
||||||
if (!result.success) {
|
|
||||||
const fieldErrors = result.error.flatten().fieldErrors
|
|
||||||
errors[field] = fieldErrors[field]?.[0] ?? ''
|
|
||||||
} else {
|
|
||||||
delete errors[field]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function validateAll(): boolean {
|
|
||||||
const result = Schema.safeParse(form)
|
|
||||||
if (!result.success) {
|
|
||||||
const flat = result.error.flatten().fieldErrors
|
|
||||||
Object.assign(errors, Object.fromEntries(Object.entries(flat).map(([k, v]) => [k, v?.[0] ?? ''])))
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
Object.keys(errors).forEach(k => delete errors[k])
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
async function submit() {
|
|
||||||
serverError.value = ''
|
|
||||||
if (!validateAll()) {
|
|
||||||
await nextTick()
|
|
||||||
const firstError = document.querySelector('.field-error')
|
|
||||||
firstError?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
submitting.value = true
|
|
||||||
try {
|
|
||||||
await $fetch('/api/submit', {
|
|
||||||
method: 'POST',
|
|
||||||
body: {
|
|
||||||
submission_type: 'job',
|
|
||||||
nom: form.nom,
|
|
||||||
url: form.url,
|
|
||||||
description_user: form.description_user,
|
|
||||||
type_plateforme: form.type_plateforme || undefined,
|
|
||||||
submitted_by_email: form.submitted_by_email || undefined,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
emit('success')
|
|
||||||
} catch (e: any) {
|
|
||||||
const status = e?.status ?? e?.statusCode
|
|
||||||
if (status === 429) {
|
|
||||||
serverError.value = 'Tu as déjà soumis 3 fiches aujourd\'hui. Réessaie demain.'
|
|
||||||
} else if (status === 422 && e?.data) {
|
|
||||||
const fieldErrors = e.data
|
|
||||||
Object.entries(fieldErrors).forEach(([k, v]) => { errors[k] = Array.isArray(v) ? v[0] : String(v) })
|
|
||||||
serverError.value = 'Certains champs sont invalides — vérifie les erreurs ci-dessus.'
|
|
||||||
} else {
|
|
||||||
serverError.value = 'Une erreur s\'est produite. Réessaie dans quelques instants.'
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
submitting.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
@import './form-styles.css';
|
|
||||||
</style>
|
|
||||||
@@ -1,310 +0,0 @@
|
|||||||
<template>
|
|
||||||
<form @submit.prevent="submit" class="proposer-form proposer-form--libre" novalidate>
|
|
||||||
<div class="field-group" :class="{ 'field-error': errors.texte }">
|
|
||||||
<label id="libre-liens-label">Tes liens <span class="label-hint">(1 ou plusieurs — sites, PDF, articles...)</span></label>
|
|
||||||
<div class="liens-list" role="group" aria-labelledby="libre-liens-label">
|
|
||||||
<div v-for="(lien, i) in liens" :key="i" class="lien-row">
|
|
||||||
<input
|
|
||||||
:id="`libre-lien-${i}`"
|
|
||||||
v-model="liens[i]"
|
|
||||||
type="url"
|
|
||||||
inputmode="url"
|
|
||||||
placeholder="https://..."
|
|
||||||
autocomplete="off"
|
|
||||||
@blur="validateAll(false)"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
v-if="liens.length > 1"
|
|
||||||
type="button"
|
|
||||||
class="lien-remove"
|
|
||||||
:aria-label="`Retirer ce lien`"
|
|
||||||
@click="removeLien(i)"
|
|
||||||
>×</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button v-if="liens.length < 10" type="button" class="btn-add-lien" @click="addLien">+ un autre lien</button>
|
|
||||||
<span v-if="errors.texte" id="libre-texte-error" class="error-msg" role="alert">{{ errors.texte }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field-group">
|
|
||||||
<label for="libre-texte">Pourquoi c'est utile, pour toi ? <span class="label-hint">(pas de format imposé)</span></label>
|
|
||||||
<textarea
|
|
||||||
id="libre-texte"
|
|
||||||
v-model="texte"
|
|
||||||
rows="4"
|
|
||||||
placeholder="Dis-le avec tes mots : ce que ça t'a apporté, pourquoi tu le partages..."
|
|
||||||
:aria-describedby="errors.texte ? 'libre-texte-error' : undefined"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field-group">
|
|
||||||
<label id="libre-type-label">Type suggéré <span class="label-hint">(optionnel — un ou deux si tu hésites)</span></label>
|
|
||||||
<ChipGroup aria-label="Type suggéré" aria-describedby="libre-type-hint">
|
|
||||||
<Chip
|
|
||||||
v-for="t in TYPES_SUGGERES"
|
|
||||||
:key="t"
|
|
||||||
:label="TYPES_SUGGERES_LABELS[t]"
|
|
||||||
variant="fonction"
|
|
||||||
:active="typeSuggere === t || typeSecondaire === t"
|
|
||||||
:class="{ 'chip--secondaire': typeSecondaire === t }"
|
|
||||||
@toggle="toggleType(t)"
|
|
||||||
/>
|
|
||||||
</ChipGroup>
|
|
||||||
<span id="libre-type-hint" class="label-hint">
|
|
||||||
Écosystème : {{ TYPES_SUGGERES_HINTS.ecosysteme }} · Réseau : {{ TYPES_SUGGERES_HINTS.reseau }} · Jobs : {{ TYPES_SUGGERES_HINTS.jobs }} · Outils : {{ TYPES_SUGGERES_HINTS.outils }} · Références : {{ TYPES_SUGGERES_HINTS.references }}
|
|
||||||
</span>
|
|
||||||
<!-- Distinction non visuelle premier choix / second choix pour lecteurs d'écran :
|
|
||||||
les deux chips actifs partagent le même aria-pressed, sans quoi rien ne dit
|
|
||||||
lequel est principal. -->
|
|
||||||
<span class="sr-only" aria-live="polite">
|
|
||||||
<template v-if="typeSuggere && typeSecondaire">
|
|
||||||
Principal : {{ TYPES_SUGGERES_LABELS[typeSuggere] }} · second choix : {{ TYPES_SUGGERES_LABELS[typeSecondaire] }}
|
|
||||||
</template>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field-group" :class="{ 'field-error': errors.submitted_by_email }">
|
|
||||||
<label for="libre-email">Ton email <span class="label-hint">(optionnel — pour qu'on te tienne au courant)</span></label>
|
|
||||||
<input
|
|
||||||
id="libre-email"
|
|
||||||
v-model="email"
|
|
||||||
type="email"
|
|
||||||
placeholder="ton@email.fr"
|
|
||||||
autocomplete="email"
|
|
||||||
@blur="validateAll(false)"
|
|
||||||
/>
|
|
||||||
<span v-if="errors.submitted_by_email" class="error-msg" role="alert">{{ errors.submitted_by_email }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Honeypot anti-spam : caché visuellement et aux lecteurs d'écran, un bot le remplit. -->
|
|
||||||
<div class="hp-field" aria-hidden="true">
|
|
||||||
<label for="libre-site-web">Site web</label>
|
|
||||||
<input id="libre-site-web" v-model="siteWeb" type="text" tabindex="-1" autocomplete="off" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="serverError" class="server-error" role="alert" aria-live="assertive">
|
|
||||||
<strong>Erreur :</strong> {{ serverError }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-actions">
|
|
||||||
<button type="submit" class="btn-primary" :disabled="submitting">
|
|
||||||
{{ submitting ? 'Envoi en cours...' : 'Proposer →' }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<p class="form-note">Ta proposition sera examinée par l'équipe avant publication.</p>
|
|
||||||
</form>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { LibreSubmitSchema, TYPES_SUGGERES, TYPES_SUGGERES_LABELS, TYPES_SUGGERES_HINTS, type TypeSuggere } from '~/utils/submitLibre'
|
|
||||||
|
|
||||||
const props = defineProps<{ presetType?: TypeSuggere }>()
|
|
||||||
const emit = defineEmits<{ success: [] }>()
|
|
||||||
|
|
||||||
const liens = reactive<string[]>([''])
|
|
||||||
const texte = ref('')
|
|
||||||
const email = ref('')
|
|
||||||
const typeSuggere = ref<TypeSuggere | undefined>(props.presetType)
|
|
||||||
// Deuxième chip optionnelle (B5-M2b) — pour les indécis entre deux types.
|
|
||||||
const typeSecondaire = ref<TypeSuggere | undefined>()
|
|
||||||
const siteWeb = ref('') // honeypot
|
|
||||||
|
|
||||||
const errors = reactive<Record<string, string>>({})
|
|
||||||
const submitting = ref(false)
|
|
||||||
const serverError = ref('')
|
|
||||||
|
|
||||||
function addLien() {
|
|
||||||
if (liens.length < 10) liens.push('')
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeLien(i: number) {
|
|
||||||
liens.splice(i, 1)
|
|
||||||
if (liens.length === 0) liens.push('')
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Deux chips maximum. Premier clic = principal, deuxième = secondaire.
|
|
||||||
* Décocher le principal alors qu'un secondaire existe promeut ce dernier.
|
|
||||||
* Troisième clic sur un chip inactif quand deux sont déjà actifs : remplace
|
|
||||||
* le secondaire par le nouveau chip (choix le plus simple — pas de file d'attente).
|
|
||||||
*/
|
|
||||||
function toggleType(t: TypeSuggere) {
|
|
||||||
if (typeSuggere.value === t) {
|
|
||||||
typeSuggere.value = typeSecondaire.value
|
|
||||||
typeSecondaire.value = undefined
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (typeSecondaire.value === t) {
|
|
||||||
typeSecondaire.value = undefined
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (typeSuggere.value === undefined) {
|
|
||||||
typeSuggere.value = t
|
|
||||||
} else if (typeSecondaire.value === undefined) {
|
|
||||||
typeSecondaire.value = t
|
|
||||||
} else {
|
|
||||||
typeSecondaire.value = t
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function currentPayload() {
|
|
||||||
return {
|
|
||||||
liens: liens.map((l) => l.trim()).filter(Boolean),
|
|
||||||
texte: texte.value,
|
|
||||||
submitted_by_email: email.value || undefined,
|
|
||||||
type_suggere: typeSuggere.value,
|
|
||||||
type_secondaire: typeSecondaire.value,
|
|
||||||
site_web: siteWeb.value,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function validateAll(scrollToError = true): boolean {
|
|
||||||
const result = LibreSubmitSchema.safeParse(currentPayload())
|
|
||||||
if (!result.success) {
|
|
||||||
const flat = result.error.flatten().fieldErrors
|
|
||||||
Object.keys(errors).forEach((k) => delete errors[k])
|
|
||||||
Object.assign(errors, Object.fromEntries(Object.entries(flat).map(([k, v]) => [k, v?.[0] ?? ''])))
|
|
||||||
if (scrollToError) {
|
|
||||||
nextTick(() => {
|
|
||||||
document.querySelector('.field-error')?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
Object.keys(errors).forEach((k) => delete errors[k])
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
async function submit() {
|
|
||||||
serverError.value = ''
|
|
||||||
if (!validateAll(true)) return
|
|
||||||
|
|
||||||
submitting.value = true
|
|
||||||
try {
|
|
||||||
await $fetch('/api/submit', {
|
|
||||||
method: 'POST',
|
|
||||||
body: { mode: 'libre', ...currentPayload() },
|
|
||||||
})
|
|
||||||
emit('success')
|
|
||||||
} catch (e: any) {
|
|
||||||
const status = e?.status ?? e?.statusCode
|
|
||||||
if (status === 429) {
|
|
||||||
serverError.value = 'Tu as déjà soumis 3 fiches aujourd\'hui. Réessaie demain.'
|
|
||||||
} else if (status === 422 && e?.data) {
|
|
||||||
Object.assign(errors, Object.fromEntries(Object.entries(e.data as Record<string, string[]>).map(([k, v]) => [k, Array.isArray(v) ? v[0] : String(v)])))
|
|
||||||
serverError.value = 'Vérifie les erreurs ci-dessus.'
|
|
||||||
} else {
|
|
||||||
serverError.value = 'Une erreur s\'est produite. Réessaie dans quelques instants.'
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
submitting.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
@import './form-styles.css';
|
|
||||||
|
|
||||||
.liens-list {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.5rem;
|
|
||||||
margin-top: 0.375rem;
|
|
||||||
}
|
|
||||||
.lien-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
.lien-row input {
|
|
||||||
flex: 1;
|
|
||||||
padding: 0.625rem 0.875rem;
|
|
||||||
border: 1px solid rgba(26, 34, 56, 0.2);
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
color: var(--nav-text);
|
|
||||||
background: var(--nav-surface);
|
|
||||||
font-family: inherit;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
.lien-row input:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: var(--nav-primary-solid);
|
|
||||||
box-shadow: 0 0 0 2px rgba(245, 179, 66, 0.4);
|
|
||||||
}
|
|
||||||
.lien-remove {
|
|
||||||
flex-shrink: 0;
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
border: 1px solid rgba(26, 34, 56, 0.2);
|
|
||||||
background: transparent;
|
|
||||||
border-radius: 6px;
|
|
||||||
color: var(--nav-text-muted);
|
|
||||||
font-size: 1.1rem;
|
|
||||||
line-height: 1;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.lien-remove:hover {
|
|
||||||
border-color: #c0392b;
|
|
||||||
color: #c0392b;
|
|
||||||
}
|
|
||||||
.btn-add-lien {
|
|
||||||
align-self: flex-start;
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
padding: 0.4rem 0.75rem;
|
|
||||||
background: transparent;
|
|
||||||
border: 1px dashed rgba(26, 34, 56, 0.3);
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
color: var(--nav-text-muted);
|
|
||||||
cursor: pointer;
|
|
||||||
font-family: inherit;
|
|
||||||
}
|
|
||||||
.btn-add-lien:hover {
|
|
||||||
border-color: var(--nav-primary-solid);
|
|
||||||
color: var(--nav-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hp-field {
|
|
||||||
position: absolute;
|
|
||||||
width: 1px;
|
|
||||||
height: 1px;
|
|
||||||
overflow: hidden;
|
|
||||||
clip: rect(0 0 0 0);
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sr-only {
|
|
||||||
position: absolute;
|
|
||||||
width: 1px;
|
|
||||||
height: 1px;
|
|
||||||
overflow: hidden;
|
|
||||||
clip: rect(0 0 0 0);
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── B5-M2c : distinction principal / second choix ──
|
|
||||||
Le principal actif (fond plein, ≈14,6:1) et le survol gardé par
|
|
||||||
@media (hover: hover) viennent de Chip.vue depuis AF5 : les surcharges
|
|
||||||
locales de AF4 (principal plein, survol collant neutralisé sur tactile)
|
|
||||||
ont été retirées. */
|
|
||||||
|
|
||||||
/* Chip secondaire (deuxième choix) : contour au lieu de plein, pour ne
|
|
||||||
jamais se confondre avec le principal — ni à l'œil ni au clic. Texte
|
|
||||||
--nav-text sur le fond de page : contraste très large (>10:1), stable
|
|
||||||
dans tous les états. */
|
|
||||||
.field-group :deep(.chip--secondaire.chip--active) {
|
|
||||||
background: transparent !important;
|
|
||||||
border-color: var(--nav-primary-solid) !important;
|
|
||||||
color: var(--nav-text) !important;
|
|
||||||
}
|
|
||||||
/* Survol du secondaire : pointeur capable de survol seulement, comme Chip.vue
|
|
||||||
(sur tactile, :hover reste collé après un tap). */
|
|
||||||
@media (hover: hover) {
|
|
||||||
.field-group :deep(.chip--secondaire.chip--active:hover) {
|
|
||||||
background: rgba(var(--nav-primary-raw), 0.08) !important;
|
|
||||||
border-color: var(--nav-primary-solid) !important;
|
|
||||||
color: var(--nav-text) !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,134 +0,0 @@
|
|||||||
<template>
|
|
||||||
<form @submit.prevent="submit" class="proposer-form" novalidate>
|
|
||||||
<div class="field-group" :class="{ 'field-error': errors.nom }">
|
|
||||||
<label for="out-nom">Nom de l'outil <span class="required">*</span></label>
|
|
||||||
<input id="out-nom" v-model="form.nom" type="text" placeholder="Ex : Simulateur Autonomie, FreeCAD..." @blur="validateField('nom')" />
|
|
||||||
<span v-if="errors.nom" class="error-msg" role="alert">{{ errors.nom }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field-group">
|
|
||||||
<label>Catégorie <span class="label-hint">(optionnelle)</span></label>
|
|
||||||
<div class="radio-group">
|
|
||||||
<label v-for="c in CATEGORIES" :key="c" class="radio-label" :class="{ active: form.categorie === c }">
|
|
||||||
<input type="radio" :value="c" v-model="form.categorie" name="out-categorie" />{{ LABELS[c] }}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field-group" :class="{ 'field-error': errors.url }">
|
|
||||||
<label for="out-url">Site web / lien <span class="label-hint">(optionnel — si pas de lien direct)</span></label>
|
|
||||||
<input id="out-url" v-model="form.url" type="url" placeholder="https://..." @blur="validateField('url')" />
|
|
||||||
<span v-if="errors.url" class="error-msg" role="alert">{{ errors.url }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field-group" :class="{ 'field-error': errors.description_user }">
|
|
||||||
<label for="out-desc">Description courte <span class="required">*</span> <span class="label-hint">(50 à 500 caractères)</span></label>
|
|
||||||
<textarea id="out-desc" v-model="form.description_user" rows="4" placeholder="Décris l'outil : à quoi il sert, pour quel usage, ce qui le rend utile..." @blur="validateField('description_user')" />
|
|
||||||
<div class="field-meta">
|
|
||||||
<span v-if="errors.description_user" class="error-msg" role="alert">{{ errors.description_user }}</span>
|
|
||||||
<span v-else class="char-count" :class="{ 'char-warn': form.description_user.length > 450 }">{{ form.description_user.length }}/500</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field-group" :class="{ 'field-error': errors.submitted_by_email }">
|
|
||||||
<label for="out-email">Ton email <span class="label-hint">(optionnel — pour le suivi)</span></label>
|
|
||||||
<input id="out-email" v-model="form.submitted_by_email" type="email" placeholder="ton@email.fr" autocomplete="email" @blur="validateField('submitted_by_email')" />
|
|
||||||
<span v-if="errors.submitted_by_email" class="error-msg" role="alert">{{ errors.submitted_by_email }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="serverError" class="server-error" role="alert"><strong>Erreur :</strong> {{ serverError }}</div>
|
|
||||||
|
|
||||||
<div class="form-actions">
|
|
||||||
<button type="submit" class="btn-primary" :disabled="submitting">{{ submitting ? 'Envoi en cours...' : 'Proposer →' }}</button>
|
|
||||||
</div>
|
|
||||||
<p class="form-note">Ta proposition sera examinée par l'équipe avant publication.</p>
|
|
||||||
</form>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { z } from 'zod'
|
|
||||||
|
|
||||||
const CATEGORIES = ['simulateur', 'logiciel', 'guide', 'autre'] as const
|
|
||||||
const LABELS: Record<string, string> = { simulateur: 'Simulateur', logiciel: 'Logiciel / app', guide: 'Guide / ressource', autre: 'Autre' }
|
|
||||||
|
|
||||||
const Schema = z.object({
|
|
||||||
nom: z.string().min(3, 'Minimum 3 caractères').max(150, 'Maximum 150 caractères').trim(),
|
|
||||||
url: z.string().url('URL invalide').optional().or(z.literal('')),
|
|
||||||
description_user: z.string().min(50, 'Minimum 50 caractères').max(500, 'Maximum 500 caractères').trim(),
|
|
||||||
categorie: z.string().optional(),
|
|
||||||
submitted_by_email: z.string().email('Email invalide').optional().or(z.literal('')),
|
|
||||||
})
|
|
||||||
|
|
||||||
const emit = defineEmits<{ success: [] }>()
|
|
||||||
|
|
||||||
const form = reactive({
|
|
||||||
nom: '', url: '', description_user: '', categorie: '', submitted_by_email: '',
|
|
||||||
})
|
|
||||||
const errors = reactive<Record<string, string>>({})
|
|
||||||
const submitting = ref(false)
|
|
||||||
const serverError = ref('')
|
|
||||||
|
|
||||||
function validateField(field: string) {
|
|
||||||
const partial = Schema.partial()
|
|
||||||
const result = partial.safeParse({ [field]: (form as any)[field] })
|
|
||||||
if (!result.success) {
|
|
||||||
const fieldErrors = result.error.flatten().fieldErrors
|
|
||||||
errors[field] = fieldErrors[field]?.[0] ?? ''
|
|
||||||
} else {
|
|
||||||
delete errors[field]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function validateAll(): boolean {
|
|
||||||
const result = Schema.safeParse(form)
|
|
||||||
if (!result.success) {
|
|
||||||
const flat = result.error.flatten().fieldErrors
|
|
||||||
Object.assign(errors, Object.fromEntries(Object.entries(flat).map(([k, v]) => [k, v?.[0] ?? ''])))
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
Object.keys(errors).forEach(k => delete errors[k])
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
async function submit() {
|
|
||||||
serverError.value = ''
|
|
||||||
if (!validateAll()) {
|
|
||||||
await nextTick()
|
|
||||||
const firstError = document.querySelector('.field-error')
|
|
||||||
firstError?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
submitting.value = true
|
|
||||||
try {
|
|
||||||
await $fetch('/api/submit', {
|
|
||||||
method: 'POST',
|
|
||||||
body: {
|
|
||||||
submission_type: 'outil',
|
|
||||||
nom: form.nom,
|
|
||||||
url: form.url || undefined,
|
|
||||||
description_user: form.description_user,
|
|
||||||
categorie: form.categorie || undefined,
|
|
||||||
submitted_by_email: form.submitted_by_email || undefined,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
emit('success')
|
|
||||||
} catch (e: any) {
|
|
||||||
const status = e?.status ?? e?.statusCode
|
|
||||||
if (status === 429) {
|
|
||||||
serverError.value = 'Tu as déjà soumis 3 fiches aujourd\'hui. Réessaie demain.'
|
|
||||||
} else if (status === 422 && e?.data) {
|
|
||||||
const fieldErrors = e.data
|
|
||||||
Object.entries(fieldErrors).forEach(([k, v]) => { errors[k] = Array.isArray(v) ? v[0] : String(v) })
|
|
||||||
serverError.value = 'Certains champs sont invalides — vérifie les erreurs ci-dessus.'
|
|
||||||
} else {
|
|
||||||
serverError.value = 'Une erreur s\'est produite. Réessaie dans quelques instants.'
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
submitting.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
@import './form-styles.css';
|
|
||||||
</style>
|
|
||||||
@@ -1,142 +0,0 @@
|
|||||||
<template>
|
|
||||||
<form @submit.prevent="submit" class="proposer-form" novalidate>
|
|
||||||
<div class="field-group" :class="{ 'field-error': errors.titre }">
|
|
||||||
<label for="ref-titre">Titre du livre / document <span class="required">*</span></label>
|
|
||||||
<input id="ref-titre" v-model="form.titre" type="text" placeholder="Ex : Repenser l'architecture..." @blur="validateField('titre')" />
|
|
||||||
<span v-if="errors.titre" class="error-msg" role="alert">{{ errors.titre }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field-group" :class="{ 'field-error': errors.auteur }">
|
|
||||||
<label for="ref-auteur">Auteur·ice(s) <span class="required">*</span></label>
|
|
||||||
<input id="ref-auteur" v-model="form.auteur" type="text" placeholder="Ex : Philippe Rahm, Doina Petrescu..." @blur="validateField('auteur')" />
|
|
||||||
<span v-if="errors.auteur" class="error-msg" role="alert">{{ errors.auteur }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field-group" :class="{ 'field-error': errors.url }">
|
|
||||||
<label for="ref-url">Lien <span class="label-hint">(optionnel — éditeur, статье, PDF…)</span></label>
|
|
||||||
<input id="ref-url" v-model="form.url" type="url" placeholder="https://..." @blur="validateField('url')" />
|
|
||||||
<span v-if="errors.url" class="error-msg" role="alert">{{ errors.url }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field-group" :class="{ 'field-error': errors.description }">
|
|
||||||
<label for="ref-desc">Description de l'ouvrage <span class="required">*</span> <span class="label-hint">(50 à 1000 caractères)</span></label>
|
|
||||||
<textarea id="ref-desc" v-model="form.description" rows="4" placeholder="Présente l'ouvrage : thèse principale, approche, pourquoi c'est important..." @blur="validateField('description')" />
|
|
||||||
<div class="field-meta">
|
|
||||||
<span v-if="errors.description" class="error-msg" role="alert">{{ errors.description }}</span>
|
|
||||||
<span v-else class="char-count" :class="{ 'char-warn': form.description.length > 900 }">{{ form.description.length }}/1000</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field-group">
|
|
||||||
<label for="ref-pertinence">Pertinence pour le RAG <span class="label-hint">(optionnel — en quoi cette référence est utile ?)</span></label>
|
|
||||||
<textarea id="ref-pertinence" v-model="form.pertinence_rag" rows="2" placeholder="Ex : Excellente source sur l'architecture participative..." maxlength="500" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field-group">
|
|
||||||
<label for="ref-hashtags">Hashtags <span class="label-hint">(optionnel — séparés par des virgules)</span></label>
|
|
||||||
<input id="ref-hashtags" v-model="form.hashtags" type="text" placeholder="Ex : écologie, participation, matériaux bio..." />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field-group" :class="{ 'field-error': errors.submitted_by_email }">
|
|
||||||
<label for="ref-email">Ton email <span class="label-hint">(optionnel — pour le suivi)</span></label>
|
|
||||||
<input id="ref-email" v-model="form.submitted_by_email" type="email" placeholder="ton@email.fr" autocomplete="email" @blur="validateField('submitted_by_email')" />
|
|
||||||
<span v-if="errors.submitted_by_email" class="error-msg" role="alert">{{ errors.submitted_by_email }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="serverError" class="server-error" role="alert"><strong>Erreur :</strong> {{ serverError }}</div>
|
|
||||||
|
|
||||||
<div class="form-actions">
|
|
||||||
<button type="submit" class="btn-primary" :disabled="submitting">{{ submitting ? 'Envoi en cours...' : 'Proposer la référence →' }}</button>
|
|
||||||
</div>
|
|
||||||
<p class="form-note">Ta référence sera examinée avant d'être intégrée au fonds documentaire RAG.</p>
|
|
||||||
</form>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { z } from 'zod'
|
|
||||||
|
|
||||||
const Schema = z.object({
|
|
||||||
titre: z.string().min(3, 'Minimum 3 caractères').max(200, 'Maximum 200 caractères').trim(),
|
|
||||||
auteur: z.string().min(2, 'Minimum 2 caractères').max(200, 'Maximum 200 caractères').trim(),
|
|
||||||
url: z.string().url('URL invalide').optional().or(z.literal('')),
|
|
||||||
description: z.string().min(50, 'Minimum 50 caractères').max(1000, 'Maximum 1000 caractères').trim(),
|
|
||||||
pertinence_rag: z.string().max(500).optional(),
|
|
||||||
hashtags: z.string().max(300).optional(),
|
|
||||||
submitted_by_email: z.string().email('Email invalide').optional().or(z.literal('')),
|
|
||||||
})
|
|
||||||
|
|
||||||
const emit = defineEmits<{ success: [] }>()
|
|
||||||
|
|
||||||
const form = reactive({
|
|
||||||
titre: '', auteur: '', url: '', description: '',
|
|
||||||
pertinence_rag: '', hashtags: '', submitted_by_email: '',
|
|
||||||
})
|
|
||||||
const errors = reactive<Record<string, string>>({})
|
|
||||||
const submitting = ref(false)
|
|
||||||
const serverError = ref('')
|
|
||||||
|
|
||||||
function validateField(field: string) {
|
|
||||||
const partial = Schema.partial()
|
|
||||||
const result = partial.safeParse({ [field]: (form as any)[field] })
|
|
||||||
if (!result.success) {
|
|
||||||
const fieldErrors = result.error.flatten().fieldErrors
|
|
||||||
errors[field] = fieldErrors[field]?.[0] ?? ''
|
|
||||||
} else {
|
|
||||||
delete errors[field]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function validateAll(): boolean {
|
|
||||||
const result = Schema.safeParse(form)
|
|
||||||
if (!result.success) {
|
|
||||||
const flat = result.error.flatten().fieldErrors
|
|
||||||
Object.assign(errors, Object.fromEntries(Object.entries(flat).map(([k, v]) => [k, v?.[0] ?? ''])))
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
Object.keys(errors).forEach(k => delete errors[k])
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
async function submit() {
|
|
||||||
serverError.value = ''
|
|
||||||
if (!validateAll()) {
|
|
||||||
await nextTick()
|
|
||||||
const firstError = document.querySelector('.field-error')
|
|
||||||
firstError?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
submitting.value = true
|
|
||||||
try {
|
|
||||||
await $fetch('/api/submit/reference', {
|
|
||||||
method: 'POST',
|
|
||||||
body: {
|
|
||||||
titre: form.titre,
|
|
||||||
auteur: form.auteur,
|
|
||||||
url: form.url || undefined,
|
|
||||||
description: form.description,
|
|
||||||
pertinence_rag: form.pertinence_rag || undefined,
|
|
||||||
hashtags: form.hashtags || undefined,
|
|
||||||
submitted_by_email: form.submitted_by_email || undefined,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
emit('success')
|
|
||||||
} catch (e: any) {
|
|
||||||
const status = e?.status ?? e?.statusCode
|
|
||||||
if (status === 429) {
|
|
||||||
serverError.value = 'Tu as déjà soumis 3 fiches aujourd\'hui. Réessaie demain.'
|
|
||||||
} else if (status === 422 && e?.data) {
|
|
||||||
const fieldErrors = e.data
|
|
||||||
Object.entries(fieldErrors).forEach(([k, v]) => { errors[k] = Array.isArray(v) ? v[0] : String(v) })
|
|
||||||
serverError.value = 'Certains champs sont invalides — vérifie les erreurs ci-dessus.'
|
|
||||||
} else {
|
|
||||||
serverError.value = 'Une erreur s\'est produite. Réessaie dans quelques instants.'
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
submitting.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
@import './form-styles.css';
|
|
||||||
</style>
|
|
||||||
@@ -1,158 +0,0 @@
|
|||||||
<template>
|
|
||||||
<form @submit.prevent="submit" class="proposer-form" novalidate>
|
|
||||||
<div class="field-group" :class="{ 'field-error': errors.nom }">
|
|
||||||
<label for="res-nom">Nom du réseau / collectif / ressource <span class="required">*</span></label>
|
|
||||||
<input id="res-nom" v-model="form.nom" type="text" placeholder="Ex : Collectif des Arpenteurs, Archi'Résilience..." @blur="validateField('nom')" />
|
|
||||||
<span v-if="errors.nom" class="error-msg" role="alert">{{ errors.nom }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field-group" :class="{ 'field-error': errors.type_principal }">
|
|
||||||
<fieldset>
|
|
||||||
<legend>Type <span class="label-hint">(optionnel)</span></legend>
|
|
||||||
<div class="radio-group">
|
|
||||||
<label v-for="t in TYPES" :key="t" class="radio-label" :class="{ active: form.type_principal === t }">
|
|
||||||
<input type="radio" :value="t" v-model="form.type_principal" name="res-type" />{{ LABELS[t] }}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</fieldset>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field-group" :class="{ 'field-error': errors.url }">
|
|
||||||
<label for="res-url">Site web <span class="label-hint">(optionnel)</span></label>
|
|
||||||
<input id="res-url" v-model="form.url" type="url" placeholder="https://..." @blur="validateField('url')" />
|
|
||||||
<span v-if="errors.url" class="error-msg" role="alert">{{ errors.url }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field-group" :class="{ 'field-error': errors.description_user }">
|
|
||||||
<label for="res-desc">Description courte <span class="required">*</span> <span class="label-hint">(50 à 500 caractères)</span></label>
|
|
||||||
<textarea id="res-desc" v-model="form.description_user" rows="4" placeholder="Présente ce réseau/collectif : son objet, son échelle, ce qui le distingue..." @blur="validateField('description_user')" />
|
|
||||||
<div class="field-meta">
|
|
||||||
<span v-if="errors.description_user" class="error-msg" role="alert">{{ errors.description_user }}</span>
|
|
||||||
<span v-else class="char-count" :class="{ 'char-warn': form.description_user.length > 450 }">{{ form.description_user.length }}/500</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field-group">
|
|
||||||
<label for="res-ville">Ville principale <span class="label-hint">(optionnel)</span></label>
|
|
||||||
<input id="res-ville" v-model="form.localisation_ville" type="text" placeholder="Ex : Paris, Lyon..." />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field-group">
|
|
||||||
<label for="res-projet-lien">Lien vers un projet / réalisation <span class="label-hint">(optionnel)</span></label>
|
|
||||||
<input id="res-projet-lien" v-model="form.projet_lien" type="url" placeholder="https://..." />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field-group">
|
|
||||||
<label for="res-projet-adresse">Adresse du projet <span class="label-hint">(optionnel)</span></label>
|
|
||||||
<input id="res-projet-adresse" v-model="form.projet_adresse" type="text" placeholder="Ex : 12 rue des Lilas, 75000 Paris" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field-group" :class="{ 'field-error': errors.submitted_by_email }">
|
|
||||||
<label for="res-email">Ton email <span class="label-hint">(optionnel — pour le suivi)</span></label>
|
|
||||||
<input id="res-email" v-model="form.submitted_by_email" type="email" placeholder="ton@email.fr" autocomplete="email" @blur="validateField('submitted_by_email')" />
|
|
||||||
<span v-if="errors.submitted_by_email" class="error-msg" role="alert">{{ errors.submitted_by_email }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="serverError" class="server-error" role="alert"><strong>Erreur :</strong> {{ serverError }}</div>
|
|
||||||
|
|
||||||
<div class="form-actions">
|
|
||||||
<button type="submit" class="btn-primary" :disabled="submitting">{{ submitting ? 'Envoi en cours...' : 'Proposer →' }}</button>
|
|
||||||
</div>
|
|
||||||
<p class="form-note">Ta proposition sera examinée par l'équipe avant publication.</p>
|
|
||||||
</form>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { z } from 'zod'
|
|
||||||
|
|
||||||
const TYPES = ['agence', 'collectif', 'reseau', 'asso', 'ressource'] as const
|
|
||||||
const LABELS: Record<string, string> = { agence: 'Agence', collectif: 'Collectif', reseau: 'Réseau', asso: 'Association', ressource: 'Ressource' }
|
|
||||||
|
|
||||||
const Schema = z.object({
|
|
||||||
nom: z.string().min(3, 'Minimum 3 caractères').max(150, 'Maximum 150 caractères').trim(),
|
|
||||||
type_principal: z.string().optional(),
|
|
||||||
url: z.string().url('URL invalide').optional().or(z.literal('')),
|
|
||||||
description_user: z.string().min(50, 'Minimum 50 caractères').max(500, 'Maximum 500 caractères').trim(),
|
|
||||||
localisation_ville: z.string().max(100).optional(),
|
|
||||||
projet_lien: z.string().url('URL invalide').optional().or(z.literal('')),
|
|
||||||
projet_adresse: z.string().max(200).optional(),
|
|
||||||
submitted_by_email: z.string().email('Email invalide').optional().or(z.literal('')),
|
|
||||||
})
|
|
||||||
|
|
||||||
const emit = defineEmits<{ success: [] }>()
|
|
||||||
|
|
||||||
const form = reactive({
|
|
||||||
nom: '', type_principal: '', url: '', description_user: '',
|
|
||||||
localisation_ville: '', projet_lien: '', projet_adresse: '', submitted_by_email: '',
|
|
||||||
})
|
|
||||||
const errors = reactive<Record<string, string>>({})
|
|
||||||
const submitting = ref(false)
|
|
||||||
const serverError = ref('')
|
|
||||||
|
|
||||||
function validateField(field: string) {
|
|
||||||
const partial = Schema.partial()
|
|
||||||
const result = partial.safeParse({ [field]: (form as any)[field] })
|
|
||||||
if (!result.success) {
|
|
||||||
const fieldErrors = result.error.flatten().fieldErrors
|
|
||||||
errors[field] = fieldErrors[field]?.[0] ?? ''
|
|
||||||
} else {
|
|
||||||
delete errors[field]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function validateAll(): boolean {
|
|
||||||
const result = Schema.safeParse(form)
|
|
||||||
if (!result.success) {
|
|
||||||
const flat = result.error.flatten().fieldErrors
|
|
||||||
Object.assign(errors, Object.fromEntries(Object.entries(flat).map(([k, v]) => [k, v?.[0] ?? ''])))
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
Object.keys(errors).forEach(k => delete errors[k])
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
async function submit() {
|
|
||||||
serverError.value = ''
|
|
||||||
if (!validateAll()) {
|
|
||||||
await nextTick()
|
|
||||||
const firstError = document.querySelector('.field-error')
|
|
||||||
firstError?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
submitting.value = true
|
|
||||||
try {
|
|
||||||
await $fetch('/api/submit', {
|
|
||||||
method: 'POST',
|
|
||||||
body: {
|
|
||||||
submission_type: 'reseau',
|
|
||||||
nom: form.nom,
|
|
||||||
type_principal: form.type_principal || undefined,
|
|
||||||
url: form.url || undefined,
|
|
||||||
description_user: form.description_user,
|
|
||||||
localisation_ville: form.localisation_ville || undefined,
|
|
||||||
projet_lien: form.projet_lien || undefined,
|
|
||||||
projet_adresse: form.projet_adresse || undefined,
|
|
||||||
submitted_by_email: form.submitted_by_email || undefined,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
emit('success')
|
|
||||||
} catch (e: any) {
|
|
||||||
const status = e?.status ?? e?.statusCode
|
|
||||||
if (status === 429) {
|
|
||||||
serverError.value = 'Tu as déjà soumis 3 fiches aujourd\'hui. Réessaie demain.'
|
|
||||||
} else if (status === 422 && e?.data) {
|
|
||||||
const fieldErrors = e.data
|
|
||||||
Object.entries(fieldErrors).forEach(([k, v]) => { errors[k] = Array.isArray(v) ? v[0] : String(v) })
|
|
||||||
serverError.value = 'Certains champs sont invalides — vérifie les erreurs ci-dessus.'
|
|
||||||
} else {
|
|
||||||
serverError.value = 'Une erreur s\'est produite. Réessaie dans quelques instants.'
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
submitting.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
@import './form-styles.css';
|
|
||||||
</style>
|
|
||||||
@@ -1,886 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="graph-view" style="width: 100%; height: 100%; position: relative; background: var(--nav-bg);">
|
|
||||||
<!-- Canvas SVG pour D3 (zone centrale, marge droite pour sidebar) -->
|
|
||||||
<svg
|
|
||||||
ref="svgRef"
|
|
||||||
:style="{
|
|
||||||
width: sidebarOpen ? 'calc(100% - 200px)' : 'calc(100% - 40px)',
|
|
||||||
height: '100%',
|
|
||||||
transition: 'width 0.2s ease',
|
|
||||||
}"
|
|
||||||
></svg>
|
|
||||||
|
|
||||||
<!-- Sidebar droite (repliable) - 3 sections : AFFICHER / HASHTAGS / MODE D'EMPLOI -->
|
|
||||||
<aside
|
|
||||||
:style="{
|
|
||||||
position: 'absolute', top: '0', right: '0', bottom: '0',
|
|
||||||
width: sidebarOpen ? '200px' : '40px',
|
|
||||||
background: 'var(--nav-surface)',
|
|
||||||
borderLeft: '1px solid var(--nav-bg-alt)',
|
|
||||||
display: 'flex', flexDirection: 'column',
|
|
||||||
transition: 'width 0.2s ease',
|
|
||||||
zIndex: 10,
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<!-- Toggle (toujours visible) -->
|
|
||||||
<button
|
|
||||||
@click="sidebarOpen = !sidebarOpen"
|
|
||||||
:title="sidebarOpen ? 'Replier la sidebar' : 'Deplier la sidebar'"
|
|
||||||
style="
|
|
||||||
width: 100%; height: 36px; flex-shrink: 0;
|
|
||||||
display: flex; align-items: center; justify-content: center;
|
|
||||||
background: var(--nav-bg-alt); border: none; cursor: pointer;
|
|
||||||
color: var(--nav-text-muted); font-size: 0.78rem; font-weight: 700;
|
|
||||||
border-bottom: 1px solid var(--nav-bg-alt);
|
|
||||||
"
|
|
||||||
>{{ sidebarOpen ? '>>' : '<<' }}</button>
|
|
||||||
|
|
||||||
<!-- Mode replie : label vertical -->
|
|
||||||
<div
|
|
||||||
v-if="!sidebarOpen"
|
|
||||||
style="
|
|
||||||
flex: 1; display: flex; align-items: center; justify-content: center;
|
|
||||||
writing-mode: vertical-rl; transform: rotate(180deg);
|
|
||||||
font-size: 0.7rem; font-weight: 700; color: var(--nav-text-muted);
|
|
||||||
letter-spacing: 0.12em; text-transform: uppercase;
|
|
||||||
"
|
|
||||||
>HASHTAGS ({{ activeHashtags.length }}/{{ props.allHashtags.length }})</div>
|
|
||||||
|
|
||||||
<!-- Mode deplie : 3 sections empilees -->
|
|
||||||
<template v-if="sidebarOpen">
|
|
||||||
<div style="flex: 1; overflow-y: auto; display: flex; flex-direction: column;">
|
|
||||||
|
|
||||||
<!-- SECTION 1 : AFFICHER (toggles familles / pratiques) -->
|
|
||||||
<div style="padding: 10px 12px; flex-shrink: 0;">
|
|
||||||
<div style="font-size: 0.72rem; font-weight: 700; color: var(--nav-text); text-transform: uppercase; letter-spacing: 0.08em; margin-bottom: 8px;">Afficher</div>
|
|
||||||
<label
|
|
||||||
:style="{
|
|
||||||
display: 'flex', alignItems: 'center', gap: '8px',
|
|
||||||
padding: '7px 10px', marginBottom: '4px',
|
|
||||||
borderRadius: '6px', cursor: 'pointer',
|
|
||||||
fontSize: '0.82rem', fontWeight: 600,
|
|
||||||
background: showFamilles ? 'var(--nav-bg-alt)' : 'transparent',
|
|
||||||
color: showFamilles ? 'var(--nav-text)' : 'var(--nav-text-muted)',
|
|
||||||
transition: 'all 0.12s',
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<input type="checkbox" v-model="showFamilles" style="cursor: pointer; width: 14px; height: 14px;" />
|
|
||||||
<span>Familles</span>
|
|
||||||
</label>
|
|
||||||
<label
|
|
||||||
:style="{
|
|
||||||
display: 'flex', alignItems: 'center', gap: '8px',
|
|
||||||
padding: '7px 10px',
|
|
||||||
borderRadius: '6px', cursor: 'pointer',
|
|
||||||
fontSize: '0.82rem', fontWeight: 600,
|
|
||||||
background: showPratiques ? 'var(--nav-bg-alt)' : 'transparent',
|
|
||||||
color: showPratiques ? 'var(--nav-text)' : 'var(--nav-text-muted)',
|
|
||||||
transition: 'all 0.12s',
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<input type="checkbox" v-model="showPratiques" style="cursor: pointer; width: 14px; height: 14px;" />
|
|
||||||
<span>Pratiques</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- SECTION 2 : HASHTAGS (chips groupees) -->
|
|
||||||
<div style="border-top: 1px solid var(--nav-bg-alt); margin-top: 0; padding: 10px 12px 8px; flex-shrink: 0;">
|
|
||||||
<div style="display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 6px;">
|
|
||||||
<span style="font-size: 0.72rem; font-weight: 700; color: var(--nav-text); text-transform: uppercase; letter-spacing: 0.08em;">Hashtags</span>
|
|
||||||
<span style="font-size: 0.68rem; color: var(--nav-text-muted);">{{ activeHashtags.length }} actif{{ activeHashtags.length > 1 ? 's' : '' }}</span>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
v-if="activeHashtags.length"
|
|
||||||
@click="activeHashtags = []"
|
|
||||||
style="margin-bottom: 6px; font-size: 0.68rem; color: var(--nav-text-muted); background: none; border: none; cursor: pointer; text-decoration: underline; padding: 0;"
|
|
||||||
>Tout effacer</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="flex: 1; overflow-y: auto; padding: 0 10px 10px;">
|
|
||||||
<div
|
|
||||||
v-for="group in hashtagsByFamille"
|
|
||||||
:key="group.famille"
|
|
||||||
style="margin-bottom: 10px;"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
:style="{
|
|
||||||
fontSize: '0.65rem', fontWeight: 700,
|
|
||||||
color: group.color, textTransform: 'uppercase',
|
|
||||||
letterSpacing: '0.06em', marginBottom: '4px',
|
|
||||||
paddingLeft: '2px',
|
|
||||||
}"
|
|
||||||
>{{ group.label }}</div>
|
|
||||||
<div style="display: flex; flex-wrap: wrap; gap: 3px;">
|
|
||||||
<span
|
|
||||||
v-for="tag in group.tags"
|
|
||||||
:key="tag"
|
|
||||||
style="padding: 2px 7px; border-radius: 9999px; font-size: 0.66rem; cursor: pointer; transition: all 0.12s;"
|
|
||||||
:style="activeHashtags.includes(tag)
|
|
||||||
? `background: ${group.color}; color: white; font-weight: 600;`
|
|
||||||
: 'background: var(--nav-bg-alt); color: var(--nav-text-muted);'"
|
|
||||||
@click="toggleHashtag(tag)"
|
|
||||||
>{{ tag }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- SECTION 3 : MODE D'EMPLOI -->
|
|
||||||
<div style="border-top: 1px solid var(--nav-bg-alt); padding: 10px 12px; flex-shrink: 0;">
|
|
||||||
<div style="font-size: 0.72rem; font-weight: 700; color: var(--nav-text); text-transform: uppercase; letter-spacing: 0.08em; margin-bottom: 6px;">Mode d'emploi</div>
|
|
||||||
<div style="font-size: 0.7rem; color: var(--nav-text-muted); line-height: 1.5;">
|
|
||||||
La carte croise des familles editoriales avec des pratiques (hashtags). Coche les couches a afficher, filtre par hashtag, clique sur un noeud pour en savoir plus.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<!-- Tooltip -->
|
|
||||||
<div ref="tooltipRef" style="
|
|
||||||
position: absolute; pointer-events: none;
|
|
||||||
background: var(--nav-surface); border: 1px solid var(--nav-bg-alt);
|
|
||||||
border-radius: 6px; padding: 8px 12px; font-size: 0.78rem;
|
|
||||||
color: var(--nav-text); max-width: 220px; box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
|
||||||
opacity: 0; transition: opacity 0.15s; z-index: 100;
|
|
||||||
"></div>
|
|
||||||
|
|
||||||
<!-- Popover unifie (famille OU hashtag) -->
|
|
||||||
<div
|
|
||||||
v-if="popover.open"
|
|
||||||
:style="{
|
|
||||||
position: 'absolute',
|
|
||||||
left: popover.x + 'px',
|
|
||||||
top: popover.y + 'px',
|
|
||||||
background: 'var(--nav-surface)',
|
|
||||||
border: '1px solid var(--nav-bg-alt)',
|
|
||||||
borderRadius: '8px',
|
|
||||||
padding: '12px 14px',
|
|
||||||
maxWidth: '280px',
|
|
||||||
boxShadow: '0 6px 18px rgba(0,0,0,0.18)',
|
|
||||||
zIndex: 50,
|
|
||||||
}"
|
|
||||||
@click.stop
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
@click="closePopover"
|
|
||||||
style="
|
|
||||||
position: absolute; top: 4px; right: 6px;
|
|
||||||
background: none; border: none; cursor: pointer;
|
|
||||||
font-size: 1rem; color: var(--nav-text-muted); padding: 2px 6px;
|
|
||||||
line-height: 1;
|
|
||||||
"
|
|
||||||
title="Fermer"
|
|
||||||
>x</button>
|
|
||||||
<div
|
|
||||||
:style="{
|
|
||||||
fontWeight: 700, fontSize: '0.92rem',
|
|
||||||
color: popover.color, marginBottom: '6px',
|
|
||||||
paddingRight: '14px',
|
|
||||||
}"
|
|
||||||
>{{ popover.title }}</div>
|
|
||||||
|
|
||||||
<!-- Body famille : description + compteur + 6 structures + bouton "Voir toutes" -->
|
|
||||||
<div v-if="popover.kind === 'famille'">
|
|
||||||
<div style="font-size: 0.78rem; line-height: 1.45; color: var(--nav-text); margin-bottom: 10px;">
|
|
||||||
{{ popover.body }}
|
|
||||||
</div>
|
|
||||||
<div style="font-size: 0.72rem; color: var(--nav-text-muted); margin-bottom: 6px;">
|
|
||||||
{{ popover.structures.length }} structure{{ popover.structures.length > 1 ? 's' : '' }} dans cette famille
|
|
||||||
</div>
|
|
||||||
<ul style="list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 3px;">
|
|
||||||
<li
|
|
||||||
v-for="s in popover.structures.slice(0, 6)"
|
|
||||||
:key="s.id"
|
|
||||||
@click="selectStructureFromPopover(s.id)"
|
|
||||||
style="
|
|
||||||
font-size: 0.78rem; color: var(--nav-text);
|
|
||||||
padding: 4px 6px; border-radius: 4px;
|
|
||||||
cursor: pointer; transition: background 0.1s;
|
|
||||||
display: flex; align-items: center; gap: 6px;
|
|
||||||
"
|
|
||||||
@mouseenter="(e: any) => e.currentTarget.style.background = 'var(--nav-bg-alt)'"
|
|
||||||
@mouseleave="(e: any) => e.currentTarget.style.background = 'transparent'"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
style="width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0;"
|
|
||||||
:style="`background: ${popover.color};`"
|
|
||||||
/>
|
|
||||||
<span>{{ s.nom }}</span>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
<button
|
|
||||||
v-if="popover.familleId != null"
|
|
||||||
@click="openFicheFamilleFromPopover"
|
|
||||||
style="
|
|
||||||
margin-top: 10px; width: 100%;
|
|
||||||
padding: 7px 10px; border-radius: 6px;
|
|
||||||
background: var(--nav-bg-alt); border: none;
|
|
||||||
font-size: 0.75rem; font-weight: 600; cursor: pointer;
|
|
||||||
color: var(--nav-text); transition: opacity 0.12s;
|
|
||||||
text-align: left;
|
|
||||||
"
|
|
||||||
@mouseenter="(e: any) => e.currentTarget.style.opacity = '0.7'"
|
|
||||||
@mouseleave="(e: any) => e.currentTarget.style.opacity = '1'"
|
|
||||||
>Voir toutes les {{ popover.structures.length }} pratiques -></button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Body hashtag : ligne generique + compteur + liste structures cliquables -->
|
|
||||||
<div v-else-if="popover.kind === 'hashtag'">
|
|
||||||
<div
|
|
||||||
style="
|
|
||||||
font-size: 0.72rem; color: var(--nav-text-muted);
|
|
||||||
font-style: italic; margin-bottom: 8px; line-height: 1.4;
|
|
||||||
"
|
|
||||||
>Pratique transversale - portee par {{ popover.structures.length }} structure{{ popover.structures.length > 1 ? 's' : '' }} de {{ popover.famillesCount }} famille{{ popover.famillesCount > 1 ? 's' : '' }}</div>
|
|
||||||
<ul style="list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 3px;">
|
|
||||||
<li
|
|
||||||
v-for="s in popover.structures.slice(0, 6)"
|
|
||||||
:key="s.id"
|
|
||||||
@click="selectStructureFromPopover(s.id)"
|
|
||||||
style="
|
|
||||||
font-size: 0.78rem; color: var(--nav-text);
|
|
||||||
padding: 4px 6px; border-radius: 4px;
|
|
||||||
cursor: pointer; transition: background 0.1s;
|
|
||||||
"
|
|
||||||
@mouseenter="(e: any) => e.currentTarget.style.background = 'var(--nav-bg-alt)'"
|
|
||||||
@mouseleave="(e: any) => e.currentTarget.style.background = 'transparent'"
|
|
||||||
>{{ s.nom }}</li>
|
|
||||||
</ul>
|
|
||||||
<div
|
|
||||||
v-if="popover.structures.length > 6"
|
|
||||||
style="font-size: 0.7rem; color: var(--nav-text-muted); margin-top: 6px; padding-left: 6px;"
|
|
||||||
>+ {{ popover.structures.length - 6 }} autre{{ popover.structures.length - 6 > 1 ? 's' : '' }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Fiche famille modale -->
|
|
||||||
<FicheFamilleModal
|
|
||||||
v-model="ficheFamilleOpen"
|
|
||||||
:famille-id="ficheFamilleId"
|
|
||||||
:data="props.data"
|
|
||||||
@select-structure="(id) => emit('select-structure', id)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import type { ReseauxBifurcationData } from '~/types/structure-v2'
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
data: ReseauxBifurcationData | null
|
|
||||||
allHashtags: string[]
|
|
||||||
active?: boolean
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
'select-structure': [id: string]
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const svgRef = ref<SVGElement | null>(null)
|
|
||||||
const tooltipRef = ref<HTMLElement | null>(null)
|
|
||||||
|
|
||||||
// Hashtag filter
|
|
||||||
const activeHashtags = ref<string[]>([])
|
|
||||||
const sidebarOpen = ref(true)
|
|
||||||
|
|
||||||
// Layers superposables (remplace viewMode exclusif PV2-5e)
|
|
||||||
const showFamilles = ref(true)
|
|
||||||
const showPratiques = ref(false)
|
|
||||||
|
|
||||||
function toggleHashtag(tag: string) {
|
|
||||||
activeHashtags.value = activeHashtags.value.includes(tag)
|
|
||||||
? activeHashtags.value.filter(t => t !== tag)
|
|
||||||
: [...activeHashtags.value, tag]
|
|
||||||
}
|
|
||||||
|
|
||||||
// Popover unifie (famille | hashtag)
|
|
||||||
type PopoverState = {
|
|
||||||
open: boolean
|
|
||||||
kind: 'famille' | 'hashtag' | null
|
|
||||||
x: number
|
|
||||||
y: number
|
|
||||||
title: string
|
|
||||||
body: string
|
|
||||||
color: string
|
|
||||||
structures: { id: string; nom: string }[]
|
|
||||||
familleId: number | null
|
|
||||||
famillesCount: number
|
|
||||||
}
|
|
||||||
|
|
||||||
const popover = ref<PopoverState>({
|
|
||||||
open: false,
|
|
||||||
kind: null,
|
|
||||||
x: 0,
|
|
||||||
y: 0,
|
|
||||||
title: '',
|
|
||||||
body: '',
|
|
||||||
color: '#000',
|
|
||||||
structures: [],
|
|
||||||
familleId: null,
|
|
||||||
famillesCount: 0,
|
|
||||||
})
|
|
||||||
|
|
||||||
// Fiche famille modale
|
|
||||||
const ficheFamilleOpen = ref(false)
|
|
||||||
const ficheFamilleId = ref<number | null>(null)
|
|
||||||
|
|
||||||
function closePopover() {
|
|
||||||
popover.value.open = false
|
|
||||||
popover.value.kind = null
|
|
||||||
}
|
|
||||||
|
|
||||||
function selectStructureFromPopover(id: string) {
|
|
||||||
closePopover()
|
|
||||||
emit('select-structure', id)
|
|
||||||
}
|
|
||||||
|
|
||||||
function openFicheFamilleFromPopover() {
|
|
||||||
if (popover.value.familleId == null) return
|
|
||||||
ficheFamilleId.value = popover.value.familleId
|
|
||||||
ficheFamilleOpen.value = true
|
|
||||||
closePopover()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mapping hashtag -> famille majoritaire
|
|
||||||
// En cas d'egalite : prendre la famille la plus petite (visibilite minoritaires)
|
|
||||||
const tagToFamille = computed<Record<string, number>>(() => {
|
|
||||||
if (!props.data) return {}
|
|
||||||
const counts: Record<string, Record<number, number>> = {}
|
|
||||||
props.data.structures.forEach(s => {
|
|
||||||
s.hashtags.forEach(tag => {
|
|
||||||
if (!counts[tag]) counts[tag] = {}
|
|
||||||
counts[tag][s.famille_principale] = (counts[tag][s.famille_principale] ?? 0) + 1
|
|
||||||
})
|
|
||||||
})
|
|
||||||
const familleSize: Record<number, number> = {}
|
|
||||||
props.data.structures.forEach(s => {
|
|
||||||
familleSize[s.famille_principale] = (familleSize[s.famille_principale] ?? 0) + 1
|
|
||||||
})
|
|
||||||
const out: Record<string, number> = {}
|
|
||||||
for (const tag in counts) {
|
|
||||||
const entries = Object.entries(counts[tag])
|
|
||||||
entries.sort((a, b) => {
|
|
||||||
const diff = (b[1] as number) - (a[1] as number)
|
|
||||||
if (diff !== 0) return diff
|
|
||||||
return (familleSize[Number(a[0])] ?? 0) - (familleSize[Number(b[0])] ?? 0)
|
|
||||||
})
|
|
||||||
out[tag] = Number(entries[0][0])
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
})
|
|
||||||
|
|
||||||
const hashtagsByFamille = computed(() => {
|
|
||||||
if (!props.data) return []
|
|
||||||
const map = tagToFamille.value
|
|
||||||
const groups: Record<number, string[]> = {}
|
|
||||||
props.allHashtags.forEach(tag => {
|
|
||||||
const fam = map[tag]
|
|
||||||
if (fam == null) return
|
|
||||||
if (!groups[fam]) groups[fam] = []
|
|
||||||
groups[fam].push(tag)
|
|
||||||
})
|
|
||||||
return [1, 2, 3, 4, 5, 6]
|
|
||||||
.filter(famId => groups[famId]?.length)
|
|
||||||
.map(famId => ({
|
|
||||||
famille: famId,
|
|
||||||
label: FAMILLE_LABELS[famId],
|
|
||||||
color: FAMILLE_COLORS[famId],
|
|
||||||
tags: groups[famId].sort(),
|
|
||||||
}))
|
|
||||||
})
|
|
||||||
|
|
||||||
// Structures portant un hashtag donne (pour popover)
|
|
||||||
function structuresForHashtag(tag: string): { id: string; nom: string }[] {
|
|
||||||
if (!props.data) return []
|
|
||||||
return props.data.structures
|
|
||||||
.filter(s => s.hashtags.includes(tag))
|
|
||||||
.map(s => ({ id: s.id, nom: s.nom }))
|
|
||||||
}
|
|
||||||
|
|
||||||
// IDs de structures correspondant aux hashtags actifs
|
|
||||||
const filteredStructureIds = computed(() => {
|
|
||||||
if (!props.data || !activeHashtags.value.length) return null
|
|
||||||
const ids = new Set(
|
|
||||||
props.data.structures
|
|
||||||
.filter(s => activeHashtags.value.every(h => s.hashtags.includes(h)))
|
|
||||||
.map(s => s.id)
|
|
||||||
)
|
|
||||||
return ids
|
|
||||||
})
|
|
||||||
|
|
||||||
const FAMILLE_COLORS: Record<number, string> = {
|
|
||||||
1: '#a85d3e',
|
|
||||||
2: '#c4a472',
|
|
||||||
3: '#d4a017',
|
|
||||||
4: '#5a7a4a',
|
|
||||||
5: '#3d6a8c',
|
|
||||||
6: '#6b3fa0',
|
|
||||||
}
|
|
||||||
|
|
||||||
const FAMILLE_LABELS: Record<number, string> = {
|
|
||||||
1: 'Reemploi',
|
|
||||||
2: 'Frugalite',
|
|
||||||
3: 'Social',
|
|
||||||
4: 'Collectifs',
|
|
||||||
5: 'Urbanisme',
|
|
||||||
6: 'Recherche',
|
|
||||||
}
|
|
||||||
|
|
||||||
const FAMILLE_DESCRIPTIONS: Record<number, string> = {
|
|
||||||
1: "Structures dont le geste premier est de travailler avec la matiere existante : deconstruction selective, plateformes de redistribution, filieres biosourcees et geosourcees.",
|
|
||||||
2: "Pratiques qui partent du principe qu'on peut faire mieux avec moins. Renovation profonde, materiaux locaux, sobriete choisie.",
|
|
||||||
3: "Structures dont le terrain premier est le mal-logement, la precarite, l'hospitalite. Architecture comme reponse a l'urgence sociale.",
|
|
||||||
4: "Structures qui accompagnent les projets collectifs : cooperatives d'habitat, ecovillages, accompagnement vers l'autogestion ou la renovation.",
|
|
||||||
5: "Demarches a l'echelle du territoire : villes en transition, PLU alternatifs, coalitions territoriales.",
|
|
||||||
6: "Recherche-action et production de contre-savoirs (Forensic Architecture, Rural Studio, PEROU, Centrala). Badge transversal aux familles.",
|
|
||||||
}
|
|
||||||
|
|
||||||
let simulation: any = null
|
|
||||||
let d3NodeSelection: any = null
|
|
||||||
let d3LinkSelection: any = null
|
|
||||||
|
|
||||||
async function initGraph() {
|
|
||||||
if (!svgRef.value || !props.data) return
|
|
||||||
|
|
||||||
const d3 = await import('d3')
|
|
||||||
|
|
||||||
const svgEl = svgRef.value
|
|
||||||
const width = svgEl.clientWidth || 800
|
|
||||||
const height = svgEl.clientHeight || 600
|
|
||||||
|
|
||||||
// Nettoyer
|
|
||||||
d3.select(svgEl).selectAll('*').remove()
|
|
||||||
closePopover()
|
|
||||||
|
|
||||||
const svg = d3.select(svgEl)
|
|
||||||
.attr('viewBox', `0 0 ${width} ${height}`)
|
|
||||||
|
|
||||||
// Click sur le SVG vide -> fermer popover
|
|
||||||
svg.on('click', (event: any) => {
|
|
||||||
if (event.target === svgEl) closePopover()
|
|
||||||
})
|
|
||||||
|
|
||||||
// Groupe principal avec zoom
|
|
||||||
const g = svg.append('g')
|
|
||||||
const zoomBehavior = d3.zoom<SVGElement, unknown>()
|
|
||||||
.scaleExtent([0.2, 4])
|
|
||||||
.on('zoom', (event) => {
|
|
||||||
g.attr('transform', event.transform)
|
|
||||||
closePopover()
|
|
||||||
})
|
|
||||||
|
|
||||||
svg.call(zoomBehavior as any)
|
|
||||||
|
|
||||||
const { allNodes, links } = buildNodesLinks(width, height)
|
|
||||||
|
|
||||||
// Simulation force-directed
|
|
||||||
if (simulation) simulation.stop()
|
|
||||||
|
|
||||||
// Adapter la charge selon le nombre de noeuds (mode "tout coche" = plus de repulsion)
|
|
||||||
const heavyMode = showPratiques.value && allNodes.length > 150
|
|
||||||
|
|
||||||
simulation = d3.forceSimulation(allNodes)
|
|
||||||
.force('link', d3.forceLink(links).id((d: any) => d.id)
|
|
||||||
.distance((d: any) => {
|
|
||||||
if (d.type === 'practice') return 90
|
|
||||||
return d.type === 'primary' ? 80 : 120
|
|
||||||
})
|
|
||||||
.strength((d: any) => d.strength ?? 0.5))
|
|
||||||
.force('charge', d3.forceManyBody().strength(heavyMode ? -80 : -120))
|
|
||||||
.force('center', d3.forceCenter(width / 2, height / 2))
|
|
||||||
.force('collision', d3.forceCollide().radius((d: any) => d.r + 4))
|
|
||||||
|
|
||||||
// Rendu liens
|
|
||||||
d3LinkSelection = g.append('g').selectAll('line')
|
|
||||||
.data(links)
|
|
||||||
.join('line')
|
|
||||||
.attr('stroke', (d: any) => {
|
|
||||||
if (d.type === 'practice') return 'rgba(150,150,150,0.25)'
|
|
||||||
return d.type === 'primary' ? 'rgba(150,150,150,0.45)' : 'rgba(150,150,150,0.35)'
|
|
||||||
})
|
|
||||||
.attr('stroke-width', 1.5)
|
|
||||||
.attr('stroke-dasharray', null)
|
|
||||||
|
|
||||||
// Rendu noeuds (groupes g)
|
|
||||||
d3NodeSelection = g.append('g').selectAll('g')
|
|
||||||
.data(allNodes)
|
|
||||||
.join('g')
|
|
||||||
.style('cursor', (d: any) => {
|
|
||||||
if (d.type === 'structure') return 'pointer'
|
|
||||||
if (d.type === 'family') return 'pointer'
|
|
||||||
if (d.type === 'hashtag') return 'pointer'
|
|
||||||
return 'default'
|
|
||||||
})
|
|
||||||
.call(
|
|
||||||
d3.drag<any, any>()
|
|
||||||
.on('start', (event: any, d: any) => {
|
|
||||||
if (!event.active) simulation.alphaTarget(0.3).restart()
|
|
||||||
d.fx = d.x
|
|
||||||
d.fy = d.y
|
|
||||||
closePopover()
|
|
||||||
})
|
|
||||||
.on('drag', (event: any, d: any) => {
|
|
||||||
d.fx = event.x
|
|
||||||
d.fy = event.y
|
|
||||||
})
|
|
||||||
.on('end', (event: any, d: any) => {
|
|
||||||
if (!event.active) simulation.alphaTarget(0)
|
|
||||||
if (d.type !== 'family') {
|
|
||||||
d.fx = null
|
|
||||||
d.fy = null
|
|
||||||
}
|
|
||||||
})
|
|
||||||
)
|
|
||||||
.on('click', (event: any, d: any) => {
|
|
||||||
event.stopPropagation()
|
|
||||||
if (d.type === 'structure') {
|
|
||||||
emit('select-structure', d.id)
|
|
||||||
} else if (d.type === 'family') {
|
|
||||||
openFamillePopover(d, event, svgEl)
|
|
||||||
} else if (d.type === 'hashtag') {
|
|
||||||
openHashtagPopover(d, event, svgEl)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Cercles
|
|
||||||
d3NodeSelection.append('circle')
|
|
||||||
.attr('r', (d: any) => d.r)
|
|
||||||
.attr('fill', (d: any) => {
|
|
||||||
if (d.type === 'family') return d.color
|
|
||||||
if (d.type === 'hashtag') return d.fill
|
|
||||||
return d.color + 'cc'
|
|
||||||
})
|
|
||||||
.attr('stroke', (d: any) => {
|
|
||||||
if (d.type === 'family') return 'white'
|
|
||||||
if (d.type === 'hashtag') return d.stroke
|
|
||||||
return d.color
|
|
||||||
})
|
|
||||||
.attr('stroke-width', (d: any) => {
|
|
||||||
if (d.type === 'family') return 3
|
|
||||||
if (d.type === 'hashtag') return 2
|
|
||||||
return 1.5
|
|
||||||
})
|
|
||||||
|
|
||||||
// Labels familles
|
|
||||||
d3NodeSelection.filter((d: any) => d.type === 'family')
|
|
||||||
.append('text')
|
|
||||||
.text((d: any) => d.label)
|
|
||||||
.attr('text-anchor', 'middle')
|
|
||||||
.attr('dy', '0.35em')
|
|
||||||
.attr('font-size', '11px')
|
|
||||||
.attr('font-weight', '700')
|
|
||||||
.attr('fill', 'white')
|
|
||||||
.style('pointer-events', 'none')
|
|
||||||
|
|
||||||
// Labels hashtags : texte noir sur fond clair, tronque a 12 caracteres
|
|
||||||
d3NodeSelection.filter((d: any) => d.type === 'hashtag')
|
|
||||||
.append('text')
|
|
||||||
.text((d: any) => {
|
|
||||||
const raw = d.label as string
|
|
||||||
return raw.length > 12 ? raw.slice(0, 12) + '...' : raw
|
|
||||||
})
|
|
||||||
.attr('text-anchor', 'middle')
|
|
||||||
.attr('dy', '0.35em')
|
|
||||||
.attr('font-size', '9px')
|
|
||||||
.attr('font-weight', '600')
|
|
||||||
.attr('fill', '#2a2a2a')
|
|
||||||
.style('pointer-events', 'none')
|
|
||||||
|
|
||||||
// Labels structures : nom au-dessus du cercle, halo pour lisibilite
|
|
||||||
d3NodeSelection.filter((d: any) => d.type === 'structure')
|
|
||||||
.append('text')
|
|
||||||
.attr('class', 'graph-struct-label')
|
|
||||||
.text((d: any) => {
|
|
||||||
const raw = d.label as string
|
|
||||||
return raw.length > 22 ? raw.slice(0, 20) + '…' : raw
|
|
||||||
})
|
|
||||||
.attr('text-anchor', 'middle')
|
|
||||||
.attr('dy', (d: any) => -(d.r + 5))
|
|
||||||
.attr('font-size', '9.5px')
|
|
||||||
.attr('font-weight', '500')
|
|
||||||
.style('pointer-events', 'none')
|
|
||||||
|
|
||||||
// Tooltip hover pour structures
|
|
||||||
d3NodeSelection.filter((d: any) => d.type === 'structure')
|
|
||||||
.on('mouseenter', (_event: any, d: any) => {
|
|
||||||
if (!tooltipRef.value) return
|
|
||||||
tooltipRef.value.style.opacity = '1'
|
|
||||||
tooltipRef.value.innerHTML = `<strong>${d.label}</strong><br><span style="opacity:0.6;font-size:0.7rem;">${FAMILLE_LABELS[d.famille] ?? ''}</span>`
|
|
||||||
})
|
|
||||||
.on('mousemove', (event: any) => {
|
|
||||||
if (!tooltipRef.value || !svgEl) return
|
|
||||||
const rect = (svgEl as HTMLElement).getBoundingClientRect()
|
|
||||||
tooltipRef.value.style.left = (event.clientX - rect.left + 12) + 'px'
|
|
||||||
tooltipRef.value.style.top = (event.clientY - rect.top - 10) + 'px'
|
|
||||||
})
|
|
||||||
.on('mouseleave', () => {
|
|
||||||
if (tooltipRef.value) tooltipRef.value.style.opacity = '0'
|
|
||||||
})
|
|
||||||
|
|
||||||
// Tick - mise a jour positions
|
|
||||||
simulation.on('tick', () => {
|
|
||||||
d3LinkSelection
|
|
||||||
.attr('x1', (d: any) => d.source.x)
|
|
||||||
.attr('y1', (d: any) => d.source.y)
|
|
||||||
.attr('x2', (d: any) => d.target.x)
|
|
||||||
.attr('y2', (d: any) => d.target.y)
|
|
||||||
|
|
||||||
d3NodeSelection.attr('transform', (d: any) => `translate(${d.x},${d.y})`)
|
|
||||||
|
|
||||||
// Surlignage selon hashtags actifs
|
|
||||||
applyHashtagFilter()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildNodesLinks(width: number, height: number) {
|
|
||||||
const allNodes: any[] = []
|
|
||||||
const links: any[] = []
|
|
||||||
|
|
||||||
if (!props.data) return { allNodes, links }
|
|
||||||
|
|
||||||
const tagFamilleMap = tagToFamille.value
|
|
||||||
|
|
||||||
// Noeuds structures (toujours presents)
|
|
||||||
const structureNodes = props.data.structures.map(s => ({
|
|
||||||
id: s.id,
|
|
||||||
type: 'structure',
|
|
||||||
label: s.nom,
|
|
||||||
famille: s.famille_principale,
|
|
||||||
familles_secondaires: s.familles_secondaires ?? [],
|
|
||||||
hashtags: s.hashtags,
|
|
||||||
color: FAMILLE_COLORS[s.famille_principale] ?? '#888',
|
|
||||||
r: 8,
|
|
||||||
}))
|
|
||||||
allNodes.push(...structureNodes)
|
|
||||||
|
|
||||||
// Layer Familles : 6 noeuds famille fixes en etoile + liens primaires/secondaires
|
|
||||||
if (showFamilles.value) {
|
|
||||||
const familyNodes = [1, 2, 3, 4, 5, 6].map(id => ({
|
|
||||||
id: `family-${id}`,
|
|
||||||
type: 'family',
|
|
||||||
familleId: id,
|
|
||||||
label: FAMILLE_LABELS[id],
|
|
||||||
color: FAMILLE_COLORS[id],
|
|
||||||
r: 32,
|
|
||||||
x: width / 2 + Math.cos((id - 1) * Math.PI * 2 / 6) * 180,
|
|
||||||
y: height / 2 + Math.sin((id - 1) * Math.PI * 2 / 6) * 180,
|
|
||||||
fx: width / 2 + Math.cos((id - 1) * Math.PI * 2 / 6) * 180,
|
|
||||||
fy: height / 2 + Math.sin((id - 1) * Math.PI * 2 / 6) * 180,
|
|
||||||
}))
|
|
||||||
allNodes.push(...familyNodes)
|
|
||||||
|
|
||||||
structureNodes.forEach(s => {
|
|
||||||
links.push({
|
|
||||||
source: s.id,
|
|
||||||
target: `family-${s.famille}`,
|
|
||||||
type: 'primary',
|
|
||||||
strength: 0.55,
|
|
||||||
})
|
|
||||||
;(s.familles_secondaires as number[]).forEach((f: number) => {
|
|
||||||
links.push({
|
|
||||||
source: s.id,
|
|
||||||
target: `family-${f}`,
|
|
||||||
type: 'secondary',
|
|
||||||
strength: 0.45,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Layer Pratiques : noeuds hashtag + liens structure -> hashtag
|
|
||||||
if (showPratiques.value) {
|
|
||||||
const uniqueTags = new Set<string>()
|
|
||||||
props.data.structures.forEach(s => s.hashtags.forEach(t => uniqueTags.add(t)))
|
|
||||||
const tagsArr = Array.from(uniqueTags).sort()
|
|
||||||
|
|
||||||
// Si seul layer Pratiques actif : disposition radiale comme reference
|
|
||||||
// Si superpose avec Familles : laisser la simulation placer
|
|
||||||
const radius = Math.min(width, height) * 0.32
|
|
||||||
const hashtagNodes = tagsArr.map((tag, i) => {
|
|
||||||
const famId = tagFamilleMap[tag]
|
|
||||||
const strokeColor = famId != null ? FAMILLE_COLORS[famId] : '#888'
|
|
||||||
const node: any = {
|
|
||||||
id: `hashtag-${tag}`,
|
|
||||||
type: 'hashtag',
|
|
||||||
label: tag.startsWith('#') ? tag.slice(1) : tag,
|
|
||||||
tag,
|
|
||||||
fill: 'var(--nav-bg-alt)',
|
|
||||||
stroke: strokeColor,
|
|
||||||
color: strokeColor,
|
|
||||||
r: 22,
|
|
||||||
}
|
|
||||||
if (!showFamilles.value) {
|
|
||||||
const angle = (i / tagsArr.length) * Math.PI * 2
|
|
||||||
node.x = width / 2 + Math.cos(angle) * radius
|
|
||||||
node.y = height / 2 + Math.sin(angle) * radius
|
|
||||||
}
|
|
||||||
return node
|
|
||||||
})
|
|
||||||
allNodes.push(...hashtagNodes)
|
|
||||||
|
|
||||||
structureNodes.forEach(s => {
|
|
||||||
s.hashtags.forEach(tag => {
|
|
||||||
if (uniqueTags.has(tag)) {
|
|
||||||
links.push({
|
|
||||||
source: s.id,
|
|
||||||
target: `hashtag-${tag}`,
|
|
||||||
type: 'practice',
|
|
||||||
strength: 0.3,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return { allNodes, links }
|
|
||||||
}
|
|
||||||
|
|
||||||
function clampPopoverPosition(rect: DOMRect, evtX: number, evtY: number, w = 280, h = 180) {
|
|
||||||
const margin = 12
|
|
||||||
let x = evtX - rect.left + 14
|
|
||||||
let y = evtY - rect.top + 10
|
|
||||||
if (x + w > rect.width - margin) {
|
|
||||||
x = Math.max(margin, rect.width - w - margin)
|
|
||||||
}
|
|
||||||
if (y + h > rect.height - margin) {
|
|
||||||
y = Math.max(margin, rect.height - h - margin)
|
|
||||||
}
|
|
||||||
return { x, y }
|
|
||||||
}
|
|
||||||
|
|
||||||
function structuresForFamille(famId: number): { id: string; nom: string }[] {
|
|
||||||
if (!props.data) return []
|
|
||||||
return props.data.structures
|
|
||||||
.filter(s =>
|
|
||||||
s.famille_principale === famId
|
|
||||||
|| (s.familles_secondaires ?? []).includes(famId)
|
|
||||||
)
|
|
||||||
.sort((a, b) => a.nom.localeCompare(b.nom, 'fr'))
|
|
||||||
.map(s => ({ id: s.id, nom: s.nom }))
|
|
||||||
}
|
|
||||||
|
|
||||||
function openFamillePopover(d: any, event: any, svgEl: SVGElement) {
|
|
||||||
const rect = (svgEl as HTMLElement).getBoundingClientRect()
|
|
||||||
const famId = d.familleId as number
|
|
||||||
const desc = FAMILLE_DESCRIPTIONS[famId] ?? ''
|
|
||||||
const structures = structuresForFamille(famId)
|
|
||||||
const { x, y } = clampPopoverPosition(rect, event.clientX, event.clientY, 280, 280)
|
|
||||||
popover.value = {
|
|
||||||
open: true,
|
|
||||||
kind: 'famille',
|
|
||||||
x,
|
|
||||||
y,
|
|
||||||
title: FAMILLE_LABELS[famId] ?? '',
|
|
||||||
body: desc,
|
|
||||||
color: FAMILLE_COLORS[famId] ?? '#000',
|
|
||||||
structures,
|
|
||||||
familleId: famId,
|
|
||||||
famillesCount: 0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function openHashtagPopover(d: any, event: any, svgEl: SVGElement) {
|
|
||||||
const rect = (svgEl as HTMLElement).getBoundingClientRect()
|
|
||||||
const tag = d.tag as string
|
|
||||||
const structures = structuresForHashtag(tag)
|
|
||||||
const famId = tagToFamille.value[tag]
|
|
||||||
const color = famId != null ? FAMILLE_COLORS[famId] : '#444'
|
|
||||||
// Compter les familles distinctes parmi les porteuses (famille_principale)
|
|
||||||
const famSet = new Set<number>()
|
|
||||||
if (props.data) {
|
|
||||||
props.data.structures
|
|
||||||
.filter(s => s.hashtags.includes(tag))
|
|
||||||
.forEach(s => famSet.add(s.famille_principale))
|
|
||||||
}
|
|
||||||
const { x, y } = clampPopoverPosition(rect, event.clientX, event.clientY, 280, 220)
|
|
||||||
popover.value = {
|
|
||||||
open: true,
|
|
||||||
kind: 'hashtag',
|
|
||||||
x,
|
|
||||||
y,
|
|
||||||
title: tag.startsWith('#') ? tag : '#' + tag,
|
|
||||||
body: '',
|
|
||||||
color,
|
|
||||||
structures,
|
|
||||||
familleId: null,
|
|
||||||
famillesCount: famSet.size,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyHashtagFilter() {
|
|
||||||
if (!d3NodeSelection || !d3LinkSelection) return
|
|
||||||
if (filteredStructureIds.value) {
|
|
||||||
const ids = filteredStructureIds.value
|
|
||||||
d3NodeSelection.filter((d: any) => d.type === 'structure').select('circle')
|
|
||||||
.attr('opacity', (d: any) => ids.has(d.id) ? 1 : 0.1)
|
|
||||||
d3LinkSelection.attr('opacity', (d: any) => {
|
|
||||||
const srcId = typeof d.source === 'object' ? d.source.id : d.source
|
|
||||||
const tgtId = typeof d.target === 'object' ? d.target.id : d.target
|
|
||||||
return ids.has(srcId) || ids.has(tgtId) ? 1 : 0.05
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
d3NodeSelection.select('circle').attr('opacity', 1)
|
|
||||||
d3LinkSelection.attr('opacity', 1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Declencher quand l'onglet devient visible
|
|
||||||
watch(() => props.active, (val) => {
|
|
||||||
if (val && import.meta.client && props.data) {
|
|
||||||
requestAnimationFrame(() => requestAnimationFrame(() => initGraph()))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Relancer si les donnees arrivent apres l'activation
|
|
||||||
watch(() => props.data, (val) => {
|
|
||||||
if (val && props.active && import.meta.client) {
|
|
||||||
requestAnimationFrame(() => requestAnimationFrame(() => initGraph()))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Re-appliquer le filtre visuel sans rebuild complet
|
|
||||||
watch(activeHashtags, () => {
|
|
||||||
applyHashtagFilter()
|
|
||||||
if (simulation) simulation.alpha(0.01).restart()
|
|
||||||
}, { deep: true })
|
|
||||||
|
|
||||||
// Watchers layers : rebuild simulation
|
|
||||||
watch([showFamilles, showPratiques], () => {
|
|
||||||
closePopover()
|
|
||||||
if (import.meta.client && props.data && props.active) {
|
|
||||||
requestAnimationFrame(() => requestAnimationFrame(() => initGraph()))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Toggle sidebar : largeur SVG change -> reinit graphe apres transition CSS
|
|
||||||
watch(sidebarOpen, () => {
|
|
||||||
if (!import.meta.client || !props.active || !props.data) return
|
|
||||||
setTimeout(() => {
|
|
||||||
requestAnimationFrame(() => requestAnimationFrame(() => initGraph()))
|
|
||||||
}, 220)
|
|
||||||
})
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
if (import.meta.client && props.data && props.active) {
|
|
||||||
await nextTick()
|
|
||||||
initGraph()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
if (simulation) simulation.stop()
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
/* Labels des structures dans le graphe (D3 injecte les <text>, donc style global) */
|
|
||||||
.graph-view .graph-struct-label {
|
|
||||||
fill: var(--nav-text);
|
|
||||||
paint-order: stroke;
|
|
||||||
stroke: var(--nav-bg);
|
|
||||||
stroke-width: 3px;
|
|
||||||
stroke-linejoin: round;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="hashtag-filter" style="padding: 8px 12px; background: var(--nav-surface);">
|
|
||||||
<!-- Filtres famille — couleurs propres par famille (hors grammaire dimension Chip, non touché B3-M1) -->
|
|
||||||
<div style="margin-bottom: 6px;">
|
|
||||||
<span class="filter-label">FAMILLES</span>
|
|
||||||
<div class="chips-row">
|
|
||||||
<span
|
|
||||||
v-for="fam in FAMILLES"
|
|
||||||
:key="fam.id"
|
|
||||||
class="chip"
|
|
||||||
:style="selectedFamille === fam.id
|
|
||||||
? `background: ${fam.color}; color: white; font-weight: 600; border: 2px solid ${fam.color};`
|
|
||||||
: `background: var(--nav-bg-alt); color: ${fam.color}; border: 2px solid ${fam.color}; font-weight: 600;`"
|
|
||||||
@click="toggleFamille(fam.id)"
|
|
||||||
>{{ fam.shortLabel }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Filtres hashtags avec toggle -->
|
|
||||||
<div>
|
|
||||||
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 4px;">
|
|
||||||
<span class="filter-label">HASHTAGS</span>
|
|
||||||
<button
|
|
||||||
@click="hashtagsVisible = !hashtagsVisible"
|
|
||||||
style="font-size: 0.7rem; color: var(--nav-text-muted); background: none; border: none; cursor: pointer; text-decoration: underline; padding: 0;"
|
|
||||||
>{{ hashtagsVisible ? 'Replier' : 'Afficher (' + props.allHashtags.length + ')' }}</button>
|
|
||||||
</div>
|
|
||||||
<ChipGroup v-if="hashtagsVisible" aria-label="Filtrer par hashtag">
|
|
||||||
<Chip
|
|
||||||
v-for="tag in props.allHashtags"
|
|
||||||
:key="tag"
|
|
||||||
:label="tag"
|
|
||||||
variant="fonction"
|
|
||||||
:active="selectedHashtags.includes(tag)"
|
|
||||||
@toggle="toggleHashtag(tag)"
|
|
||||||
/>
|
|
||||||
</ChipGroup>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
const FAMILLES = [
|
|
||||||
{ id: 1, shortLabel: 'Réemploi', color: '#a85d3e' },
|
|
||||||
{ id: 2, shortLabel: 'Frugalité', color: '#c4a472' },
|
|
||||||
{ id: 3, shortLabel: 'Social', color: '#d4a017' },
|
|
||||||
{ id: 4, shortLabel: 'Collectifs', color: '#5a7a4a' },
|
|
||||||
{ id: 5, shortLabel: 'Urbanisme', color: '#3d6a8c' },
|
|
||||||
{ id: 6, shortLabel: 'Recherche', color: '#6b3fa0' },
|
|
||||||
]
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
allHashtags: string[]
|
|
||||||
selectedHashtags: string[]
|
|
||||||
selectedFamille: number | null
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
'update:selectedHashtags': [v: string[]]
|
|
||||||
'update:selectedFamille': [v: number | null]
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const hashtagsVisible = ref(false)
|
|
||||||
|
|
||||||
function toggleFamille(id: number) {
|
|
||||||
emit('update:selectedFamille', props.selectedFamille === id ? null : id)
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleHashtag(tag: string) {
|
|
||||||
const current = props.selectedHashtags
|
|
||||||
emit('update:selectedHashtags',
|
|
||||||
current.includes(tag) ? current.filter(t => t !== tag) : [...current, tag]
|
|
||||||
)
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.filter-label {
|
|
||||||
font-size: 0.7rem;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.08em;
|
|
||||||
color: var(--nav-text-muted);
|
|
||||||
display: block;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
|
||||||
.chips-row { display: flex; flex-wrap: wrap; gap: 4px; margin-bottom: 4px; }
|
|
||||||
.chip {
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 3px 10px;
|
|
||||||
border-radius: 9999px;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
transition: all 0.15s;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
<template>
|
|
||||||
<Teleport to="body">
|
|
||||||
<Transition name="fade">
|
|
||||||
<div
|
|
||||||
v-if="visible"
|
|
||||||
class="intention-overlay"
|
|
||||||
style="
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
z-index: 2000;
|
|
||||||
background: rgba(20, 18, 14, 0.85);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 24px;
|
|
||||||
"
|
|
||||||
@click.self="dismiss"
|
|
||||||
>
|
|
||||||
<div style="
|
|
||||||
max-width: 540px;
|
|
||||||
width: 100%;
|
|
||||||
background: #faf8f5;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 32px;
|
|
||||||
color: #2c2416;
|
|
||||||
">
|
|
||||||
<p style="font-size: 1rem; line-height: 1.7; margin: 0 0 12px 0;">
|
|
||||||
Cette carte recense les réseaux, collectifs, agences et projets où des
|
|
||||||
pensées écologiques deviennent des pratiques d'architecture et d'habiter.
|
|
||||||
</p>
|
|
||||||
<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ù.
|
|
||||||
Six familles et des hashtags vous permettent d'explorer.
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
@click="dismiss"
|
|
||||||
style="
|
|
||||||
background: #2c2416;
|
|
||||||
color: #faf8f5;
|
|
||||||
border: none;
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: 10px 24px;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
width: 100%;
|
|
||||||
"
|
|
||||||
>Explorer la carte</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Transition>
|
|
||||||
</Teleport>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
const visible = ref(false)
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
if (typeof localStorage !== 'undefined' && !localStorage.getItem('aep_intention_seen')) {
|
|
||||||
visible.value = true
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
function dismiss() {
|
|
||||||
visible.value = false
|
|
||||||
if (typeof localStorage !== 'undefined') {
|
|
||||||
localStorage.setItem('aep_intention_seen', '1')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.fade-enter-active, .fade-leave-active { transition: opacity 0.3s; }
|
|
||||||
.fade-enter-from, .fade-leave-to { opacity: 0; }
|
|
||||||
</style>
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="media-tab-backend" style="padding: 2rem; overflow-y: auto;">
|
|
||||||
<div style="max-width: 640px;">
|
|
||||||
<h2 style="font-weight: 700; font-size: 1.1rem; margin-bottom: 0.75rem; color: var(--nav-text);">LightRAG backend</h2>
|
|
||||||
<p style="font-size: 0.9rem; line-height: 1.6; color: var(--nav-text); margin-bottom: 0.5rem;">
|
|
||||||
Voici l'interface brute du <strong>LightRAG</strong> qui alimente la carte des pensées écologiques.
|
|
||||||
C'est la "cuisine" du RAG : ingestion de documents, extraction d'entités, relations, requêtes.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<iframe
|
|
||||||
src="https://lightrag.trans-former.fr/"
|
|
||||||
style="width: 100%; height: 70vh; border: 1px solid var(--nav-bg-alt, #ddd); border-radius: 8px; margin-top: 1.5rem;"
|
|
||||||
title="LightRAG backend AEP — lecture seule"
|
|
||||||
sandbox="allow-same-origin allow-scripts"
|
|
||||||
loading="lazy"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="media-tab-projets" style="padding: 1.5rem; overflow-y: auto;">
|
|
||||||
<div style="max-width: 70ch; margin-bottom: 1.5rem;">
|
|
||||||
<h2 style="font-weight: 700; font-size: 1.1rem; margin-bottom: 0.5rem; color: var(--nav-text);">PFE engagés</h2>
|
|
||||||
<p style="font-size: 0.9rem; line-height: 1.6; color: var(--nav-text);">
|
|
||||||
Mutualiser le savoir. Voici les PFE engagés publiés en ligne dont nous avons connaissance.
|
|
||||||
Partage-nous le lien de ton travail si tu veux participer à cette initiative.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="projets-grid">
|
|
||||||
<article v-for="p in projets" :key="p.id" class="projet-card">
|
|
||||||
<img v-if="p.thumb" :src="p.thumb" :alt="p.titre" class="projet-thumb" loading="lazy" />
|
|
||||||
<div v-else class="projet-thumb projet-thumb--placeholder">📐</div>
|
|
||||||
|
|
||||||
<h3 style="font-weight: 600; font-size: 0.95rem; margin: 0.5rem 0 0.25rem; color: var(--nav-text);">{{ p.titre }}</h3>
|
|
||||||
<p style="font-size: 0.8rem; color: var(--nav-text-muted); margin-bottom: 0.5rem;">
|
|
||||||
{{ (p.auteurs || []).filter((a: string) => a !== 'Inconnu').join(', ') }}
|
|
||||||
<template v-if="p.ecole && p.ecole !== 'Inconnu'"> · {{ p.ecole }}</template>
|
|
||||||
<template v-if="p.annee && p.annee !== 'Inconnu'"> · {{ p.annee }}</template>
|
|
||||||
</p>
|
|
||||||
<p style="font-size: 0.875rem; line-height: 1.5; color: var(--nav-text); flex: 1; margin-bottom: 0.75rem;">{{ p.description }}</p>
|
|
||||||
|
|
||||||
<a v-if="p.url" :href="p.url" target="_blank" rel="noopener" style="color: var(--nav-primary-solid, #3b6ea5); font-weight: 600; font-size: 0.875rem; text-decoration: none;">
|
|
||||||
Découvrir →
|
|
||||||
</a>
|
|
||||||
<span v-if="p.link_status === 'broken'" style="color: #e67e22; font-size: 0.8rem; display: block; margin-top: 0.25rem;">⚠ Lien d'origine cassé</span>
|
|
||||||
</article>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p style="margin-top: 2rem; font-size: 0.875rem; color: var(--nav-text-muted);">
|
|
||||||
Tu as un PFE engagé à partager ? <a href="mailto:contact@trans-former.fr" style="color: var(--nav-primary-solid);">Écris-moi</a>.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
const { data: pfeData } = await useFetch<{ projets: any[] }>('/data/pfe-engages.json')
|
|
||||||
const projets = computed(() => pfeData.value?.projets ?? [])
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.projets-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
|
||||||
gap: 1.25rem;
|
|
||||||
}
|
|
||||||
.projet-card {
|
|
||||||
border: 1px solid var(--nav-bg-alt, #eee);
|
|
||||||
border-radius: 10px;
|
|
||||||
padding: 1rem;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
background: var(--nav-surface);
|
|
||||||
}
|
|
||||||
.projet-thumb {
|
|
||||||
width: 100%;
|
|
||||||
height: 140px;
|
|
||||||
object-fit: cover;
|
|
||||||
border-radius: 6px;
|
|
||||||
background: var(--nav-bg-alt, #f5f5f5);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-size: 2rem;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,651 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="media-visuel">
|
|
||||||
|
|
||||||
<!-- Conteneur principal : carte → toolbar → chatbot (ou bonpote/rag-backend) -->
|
|
||||||
<div class="layout-container">
|
|
||||||
|
|
||||||
<!-- SLOT CARTE D3 (mode carte uniquement) -->
|
|
||||||
<div
|
|
||||||
v-if="contentView === 'carte'"
|
|
||||||
class="carte-slot"
|
|
||||||
:class="[
|
|
||||||
layoutMode === 'split' ? 'carte-split' : '',
|
|
||||||
layoutMode === 'carte-full' ? 'carte-full' : '',
|
|
||||||
layoutMode === 'chatbot-full' ? 'carte-hidden' : '',
|
|
||||||
]"
|
|
||||||
:style="layoutMode === 'split' ? { flexBasis: carteFlexBasis } : {}"
|
|
||||||
style="position: relative;"
|
|
||||||
>
|
|
||||||
<ClientOnly>
|
|
||||||
<CartePensees
|
|
||||||
ref="cartePenseesRef"
|
|
||||||
:data="penseesData"
|
|
||||||
:active="true"
|
|
||||||
@select-auteur="onSelectAuteur"
|
|
||||||
@select-ecole="onSelectEcole"
|
|
||||||
/>
|
|
||||||
<template #fallback>
|
|
||||||
<div class="w-full h-full flex items-center justify-center" style="color: var(--nav-text-muted);">
|
|
||||||
Chargement de la carte...
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</ClientOnly>
|
|
||||||
|
|
||||||
<!-- Overlay PDF FRACAS -->
|
|
||||||
<div
|
|
||||||
v-if="showFracasPdf"
|
|
||||||
class="fracas-overlay"
|
|
||||||
:style="{ opacity: fracasOpacity / 100 }"
|
|
||||||
>
|
|
||||||
<embed
|
|
||||||
src="/cartes/carte-fracas-bonpote-v2.pdf"
|
|
||||||
type="application/pdf"
|
|
||||||
style="width: 100%; height: 100%;"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- BARRE DE TOGGLE (entre carte et chatbot, toujours visible) -->
|
|
||||||
<div class="layout-toggle-bar shrink-0">
|
|
||||||
|
|
||||||
<!-- Gauche : contrôles layout (seulement en mode carte) -->
|
|
||||||
<template v-if="contentView === 'carte'">
|
|
||||||
<button
|
|
||||||
@click="layoutMode = 'carte-full'"
|
|
||||||
:class="{ active: layoutMode === 'carte-full' }"
|
|
||||||
class="toggle-btn"
|
|
||||||
title="Carte en plein ecran"
|
|
||||||
>
|
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
||||||
<polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/>
|
|
||||||
<line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/>
|
|
||||||
</svg>
|
|
||||||
Carte plein ecran
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
v-if="layoutMode !== 'split'"
|
|
||||||
@click="layoutMode = 'split'"
|
|
||||||
class="toggle-btn"
|
|
||||||
title="Vue partagee"
|
|
||||||
>
|
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
||||||
<rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="12" x2="21" y2="12"/>
|
|
||||||
</svg>
|
|
||||||
Vue partagee
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
@click="layoutMode = 'chatbot-full'"
|
|
||||||
:class="{ active: layoutMode === 'chatbot-full' }"
|
|
||||||
class="toggle-btn"
|
|
||||||
title="Chatbot plein ecran"
|
|
||||||
>
|
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
||||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
|
|
||||||
</svg>
|
|
||||||
Chatbot plein ecran
|
|
||||||
</button>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- Droite : contrôles contenu (toujours, indépendants du layoutMode) -->
|
|
||||||
<div style="margin-left: auto; display: flex; align-items: center; gap: 4px; flex-wrap: wrap;">
|
|
||||||
<!-- Slider opacité PDF -->
|
|
||||||
<input
|
|
||||||
v-if="showFracasPdf && contentView === 'carte'"
|
|
||||||
type="range"
|
|
||||||
min="0"
|
|
||||||
max="100"
|
|
||||||
v-model.number="fracasOpacity"
|
|
||||||
class="opacity-slider"
|
|
||||||
:title="`Opacité ${fracasOpacity}%`"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- CARTE PRINCIPALE -->
|
|
||||||
<button
|
|
||||||
@click="showCarte"
|
|
||||||
:class="{ active: contentView === 'carte' }"
|
|
||||||
class="toggle-btn"
|
|
||||||
title="Vue principale : carte D3 + chatbot"
|
|
||||||
>
|
|
||||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
||||||
<circle cx="12" cy="12" r="2"/><path d="M12 2a10 10 0 0 0-7.07 17.07M12 2a10 10 0 0 1 7.07 17.07M3.34 7h17.32M3.34 17h17.32"/>
|
|
||||||
</svg>
|
|
||||||
CARTE PRINCIPALE
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- Tickbox PDF + carte des pensées -->
|
|
||||||
<div class="carte-pensees-ctrl">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
v-model="showFracasPdf"
|
|
||||||
class="fracas-check"
|
|
||||||
title="Superposer la carte FRACAS en PDF"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
@click="contentView = 'bonpote'"
|
|
||||||
:class="{ active: contentView === 'bonpote' }"
|
|
||||||
class="toggle-btn carte-pensees-btn"
|
|
||||||
title="Carte des pensées écologiques — référence FRACAS Bonpote V2"
|
|
||||||
>
|
|
||||||
📗 carte des pensées écologiques
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- RAG backend -->
|
|
||||||
<button
|
|
||||||
@click="contentView = 'rag-backend'"
|
|
||||||
:class="{ active: contentView === 'rag-backend' }"
|
|
||||||
class="toggle-btn"
|
|
||||||
title="Interface LightRAG backend"
|
|
||||||
>
|
|
||||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
||||||
<circle cx="12" cy="12" r="3"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14M4.93 4.93a10 10 0 0 0 0 14.14"/>
|
|
||||||
</svg>
|
|
||||||
RAG backend
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- POIGNEE DRAGGABLE (split uniquement) -->
|
|
||||||
<div
|
|
||||||
v-if="contentView === 'carte' && layoutMode === 'split'"
|
|
||||||
class="split-handle"
|
|
||||||
@mousedown.prevent="onHandleMousedown"
|
|
||||||
title="Redimensionner"
|
|
||||||
>
|
|
||||||
<span class="split-handle-grip"></span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- SLOT CHATBOT (mode carte uniquement) -->
|
|
||||||
<div
|
|
||||||
v-if="contentView === 'carte'"
|
|
||||||
class="chatbot-slot"
|
|
||||||
:class="[
|
|
||||||
layoutMode === 'split' ? 'chatbot-split' : '',
|
|
||||||
layoutMode === 'chatbot-full' ? 'chatbot-full-mode' : '',
|
|
||||||
layoutMode === 'carte-full' ? 'chatbot-hidden' : '',
|
|
||||||
]"
|
|
||||||
:style="layoutMode === 'split' ? { flexBasis: chatbotFlexBasis } : {}"
|
|
||||||
>
|
|
||||||
<ClientOnly>
|
|
||||||
<ChatbotPensees :auteurContext="chatbotAuteur" :inline="true" />
|
|
||||||
</ClientOnly>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- VUE BONPOTE -->
|
|
||||||
<div
|
|
||||||
v-if="contentView === 'bonpote'"
|
|
||||||
class="flex-1 overflow-y-auto px-6 py-8"
|
|
||||||
style="max-width: 680px; margin: 0 auto;"
|
|
||||||
>
|
|
||||||
<div class="mb-6">
|
|
||||||
<p class="text-xs font-bold uppercase tracking-widest mb-2" style="color: var(--nav-text-muted);">Reference editoriale</p>
|
|
||||||
<h2 class="text-xl font-bold mb-3" style="color: var(--nav-text);">Carte FRACAS des pensees ecologiques</h2>
|
|
||||||
<p class="text-sm leading-relaxed mb-4" style="color: var(--nav-text);">
|
|
||||||
FRACAS (Familles, Racines et Arpentages des Courants et Alternatives Solidaires) est une carte des ecoles de pensee ecologique publiee par Bonpote en octobre 2024. Elle reference ~140 auteurs et autrices reparti-es en 10 ecoles de pensee, depuis l'ecosocialisme jusqu'a l'ethique environnementale.
|
|
||||||
</p>
|
|
||||||
<p class="text-sm leading-relaxed mb-6" style="color: var(--nav-text);">
|
|
||||||
Le RAG ATIS est construit sur cette reference : chaque auteur ingere dans la bibliotheque correspond a une entree de la carte FRACAS. Les ecoles de pensee, les positions et les couleurs de notre carte sont transposees 1:1 depuis Bonpote V2.
|
|
||||||
</p>
|
|
||||||
<div class="flex flex-col gap-3">
|
|
||||||
<a href="https://bonpote.com/la-carte-des-pensees-ecologiques/"
|
|
||||||
target="_blank" rel="noopener"
|
|
||||||
class="flex items-center gap-3 px-4 py-3 rounded-lg hover:opacity-80 transition-opacity"
|
|
||||||
style="background: var(--nav-primary, #3b6ea5); color: white; font-size: 0.875rem; font-weight: 600; text-decoration: none;">
|
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
|
|
||||||
Lire l'article Bonpote + carte interactive
|
|
||||||
</a>
|
|
||||||
<a href="https://bonpote.com/wp-content/uploads/2024/10/FRACAS_BONPOTE_CARTE_VERSO_V2-OCT2024.pdf"
|
|
||||||
target="_blank" rel="noopener"
|
|
||||||
class="flex items-center gap-3 px-4 py-3 rounded-lg hover:opacity-80 transition-opacity"
|
|
||||||
style="background: var(--nav-bg-alt); color: var(--nav-text); font-size: 0.875rem; font-weight: 500; text-decoration: none;">
|
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
|
||||||
Telecharger le poster PDF (recto/verso)
|
|
||||||
</a>
|
|
||||||
<button
|
|
||||||
@click="showCarte"
|
|
||||||
class="flex items-center gap-3 px-4 py-3 rounded-lg hover:opacity-80 transition-opacity text-left"
|
|
||||||
style="background: var(--nav-bg-alt); color: var(--nav-text); font-size: 0.875rem; font-weight: 500; border: none; cursor: pointer;">
|
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
|
|
||||||
Interroger le RAG ATIS sur ces pensees
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p class="text-xs font-bold uppercase tracking-widest mb-3" style="color: var(--nav-text-muted);">Les 10 ecoles de pensee (FRACAS V2)</p>
|
|
||||||
<div class="flex flex-col gap-2">
|
|
||||||
<div v-for="ecole in (penseesData?.ecoles ?? [])" :key="ecole.id"
|
|
||||||
class="flex items-start gap-3 px-3 py-2 rounded-lg"
|
|
||||||
style="background: var(--nav-bg-alt);">
|
|
||||||
<span class="w-3 h-3 rounded-full shrink-0 mt-1" :style="`background:${ecole.color};`"></span>
|
|
||||||
<div>
|
|
||||||
<p class="text-sm font-semibold" style="color: var(--nav-text);">{{ ecole.label }}</p>
|
|
||||||
<p class="text-xs mt-0.5 leading-relaxed" style="color: var(--nav-text-muted);">{{ ecole.description }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- VUE RAG BACKEND -->
|
|
||||||
<div
|
|
||||||
v-if="contentView === 'rag-backend'"
|
|
||||||
style="flex: 1; overflow: hidden; display: flex; flex-direction: column;"
|
|
||||||
>
|
|
||||||
<MediaTabBackend />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Fiche auteur modal -->
|
|
||||||
<FicheAuteur
|
|
||||||
:open="ficheOpen"
|
|
||||||
:auteurId="ficheAuteurId"
|
|
||||||
:data="penseesData"
|
|
||||||
@close="ficheOpen = false"
|
|
||||||
@interroger-rag="onInterrogerRag"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Fiche ecole modal -->
|
|
||||||
<FicheEcole
|
|
||||||
:open="ficheEcoleOpen"
|
|
||||||
:ecoleId="ficheEcoleId"
|
|
||||||
:data="penseesData"
|
|
||||||
@close="ficheEcoleOpen = false"
|
|
||||||
@select-auteur="onSelectAuteurFromEcole"
|
|
||||||
@interroger-ecole="onInterrogerEcole"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Modal info RAG -->
|
|
||||||
<Teleport to="body">
|
|
||||||
<Transition name="backdrop">
|
|
||||||
<div v-if="ragInfoOpen" class="fixed inset-0 z-[2000]" style="background:rgba(26,34,56,0.55);" @click="ragInfoOpen = false" aria-hidden="true" />
|
|
||||||
</Transition>
|
|
||||||
<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="À 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 - 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">
|
|
||||||
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
|
||||||
</svg>
|
|
||||||
</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;">
|
|
||||||
<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>
|
|
||||||
<p>Les textes sont vectorises dans un espace de 662 dimensions - chaque livre devient un nuage de points semantiques. La proximite entre les points capture la proximite entre les idees, pas les mots.</p>
|
|
||||||
</div>
|
|
||||||
<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;">Chunking intelligent</p>
|
|
||||||
<p>Lors de l'ingestion, nous selectionnons les entites cles (concepts, auteurs, relations entre idees) plutot que de decouper mecaniquement les textes.</p>
|
|
||||||
</div>
|
|
||||||
<div class="p-3 rounded-lg" style="background:var(--nav-bg-alt);">
|
|
||||||
<p class="font-semibold mb-2" style="font-size:0.8rem;color:var(--nav-text-muted);text-transform:uppercase;letter-spacing:0.05em;">Trois couches d'analyse</p>
|
|
||||||
<div class="flex flex-col gap-1.5">
|
|
||||||
<div class="flex gap-2"><span class="font-semibold" style="min-width:70px;">Fond</span><span>Les idees, les theses, les arguments - ce qu'on interroge directement.</span></div>
|
|
||||||
<div class="flex gap-2"><span class="font-semibold" style="min-width:70px;">Forme</span><span>Les modeles narratifs, la rhetorique, la construction argumentative.</span></div>
|
|
||||||
<div class="flex gap-2"><span class="font-semibold" style="min-width:70px;">Structure</span><span>L'architecture des livres - comment les auteurs construisent leur pensee.</span></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Transition>
|
|
||||||
</Teleport>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
interface EcoleData { id: string; label: string; description: string; color: string; x_hint: number; y_hint: number }
|
|
||||||
interface LivreRag { slug: string; titre: string; annee: number; couches: string[] }
|
|
||||||
interface AuteurData { id: string; nom: string; dates: string; ecoles: string[]; ecole_principale: string; livres_rag: LivreRag[]; theses_cles: string[]; bio_courte: string }
|
|
||||||
interface PenseesData { meta: any; ecoles: EcoleData[]; auteurs: AuteurData[] }
|
|
||||||
|
|
||||||
type LayoutMode = 'split' | 'carte-full' | 'chatbot-full'
|
|
||||||
type ContentView = 'carte' | 'bonpote' | 'rag-backend'
|
|
||||||
|
|
||||||
const LAYOUT_KEY = 'media-layout-mode'
|
|
||||||
const CONTENT_KEY = 'media-content-view'
|
|
||||||
const SPLIT_RATIO_KEY = 'media-split-ratio'
|
|
||||||
const DEFAULT_SPLIT_RATIO = 0.66
|
|
||||||
|
|
||||||
const ficheOpen = ref(false)
|
|
||||||
const ficheAuteurId = ref<string | null>(null)
|
|
||||||
const ficheEcoleOpen = ref(false)
|
|
||||||
const ficheEcoleId = ref<string | null>(null)
|
|
||||||
const ragInfoOpen = ref(false)
|
|
||||||
const chatbotAuteur = ref<string | null>(null)
|
|
||||||
|
|
||||||
const layoutMode = ref<LayoutMode>('split')
|
|
||||||
const contentView = ref<ContentView>('carte')
|
|
||||||
const cartePenseesRef = ref<{ triggerResize: () => void } | null>(null)
|
|
||||||
|
|
||||||
const showFracasPdf = ref(false)
|
|
||||||
const fracasOpacity = ref(60)
|
|
||||||
|
|
||||||
const penseesData = ref<PenseesData | null>(null)
|
|
||||||
|
|
||||||
const splitRatio = ref(DEFAULT_SPLIT_RATIO)
|
|
||||||
const carteFlexBasis = computed(() => `${splitRatio.value * 100}%`)
|
|
||||||
const chatbotFlexBasis = computed(() => `${(1 - splitRatio.value) * 100}%`)
|
|
||||||
|
|
||||||
let dragStartY = 0
|
|
||||||
let dragStartRatio = DEFAULT_SPLIT_RATIO
|
|
||||||
let containerHeight = 0
|
|
||||||
|
|
||||||
function showCarte() {
|
|
||||||
contentView.value = 'carte'
|
|
||||||
layoutMode.value = 'split'
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
localStorage.setItem(CONTENT_KEY, 'carte')
|
|
||||||
localStorage.setItem(LAYOUT_KEY, 'split')
|
|
||||||
}
|
|
||||||
nextTick(() => cartePenseesRef.value?.triggerResize())
|
|
||||||
}
|
|
||||||
|
|
||||||
function onHandleMousedown(e: MouseEvent) {
|
|
||||||
dragStartY = e.clientY
|
|
||||||
dragStartRatio = splitRatio.value
|
|
||||||
const container = (e.target as HTMLElement)?.closest('.layout-container') as HTMLElement | null
|
|
||||||
containerHeight = container ? container.clientHeight : window.innerHeight
|
|
||||||
window.addEventListener('mousemove', onHandleMousemove)
|
|
||||||
window.addEventListener('mouseup', onHandleMouseup)
|
|
||||||
}
|
|
||||||
|
|
||||||
function onHandleMousemove(e: MouseEvent) {
|
|
||||||
const delta = e.clientY - dragStartY
|
|
||||||
const newRatio = dragStartRatio + delta / containerHeight
|
|
||||||
splitRatio.value = Math.min(0.80, Math.max(0.20, newRatio))
|
|
||||||
}
|
|
||||||
|
|
||||||
function onHandleMouseup() {
|
|
||||||
window.removeEventListener('mousemove', onHandleMousemove)
|
|
||||||
window.removeEventListener('mouseup', onHandleMouseup)
|
|
||||||
if (typeof window !== 'undefined') localStorage.setItem(SPLIT_RATIO_KEY, String(splitRatio.value))
|
|
||||||
cartePenseesRef.value?.triggerResize()
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
const savedLayout = localStorage.getItem(LAYOUT_KEY) as LayoutMode | null
|
|
||||||
if (savedLayout && (['split', 'carte-full', 'chatbot-full'] as string[]).includes(savedLayout)) {
|
|
||||||
layoutMode.value = savedLayout
|
|
||||||
}
|
|
||||||
const savedContent = localStorage.getItem(CONTENT_KEY) as ContentView | null
|
|
||||||
if (savedContent && (['carte', 'bonpote', 'rag-backend'] as string[]).includes(savedContent)) {
|
|
||||||
contentView.value = savedContent
|
|
||||||
}
|
|
||||||
const savedRatio = parseFloat(localStorage.getItem(SPLIT_RATIO_KEY) ?? '')
|
|
||||||
if (!isNaN(savedRatio) && savedRatio >= 0.20 && savedRatio <= 0.80) {
|
|
||||||
splitRatio.value = savedRatio
|
|
||||||
}
|
|
||||||
if (!localStorage.getItem('rag-fracas-info-seen')) {
|
|
||||||
ragInfoOpen.value = true
|
|
||||||
localStorage.setItem('rag-fracas-info-seen', '1')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
penseesData.value = await $fetch<PenseesData>('/data/auteurs-pensees.json')
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Erreur chargement auteurs-pensees.json', e)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
watch(layoutMode, (v) => {
|
|
||||||
if (typeof window !== 'undefined') localStorage.setItem(LAYOUT_KEY, v)
|
|
||||||
if (v === 'split' || v === 'carte-full') {
|
|
||||||
setTimeout(() => cartePenseesRef.value?.triggerResize(), 350)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
watch(contentView, (v) => {
|
|
||||||
if (typeof window !== 'undefined') localStorage.setItem(CONTENT_KEY, v)
|
|
||||||
})
|
|
||||||
|
|
||||||
function onSelectAuteur(id: string) {
|
|
||||||
ficheAuteurId.value = id
|
|
||||||
ficheOpen.value = true
|
|
||||||
chatbotAuteur.value = null
|
|
||||||
}
|
|
||||||
|
|
||||||
function onSelectEcole(id: string) {
|
|
||||||
ficheEcoleId.value = id
|
|
||||||
ficheEcoleOpen.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
function onSelectAuteurFromEcole(auteurId: string) {
|
|
||||||
ficheEcoleOpen.value = false
|
|
||||||
onSelectAuteur(auteurId)
|
|
||||||
}
|
|
||||||
|
|
||||||
function onInterrogerEcole(ecoleId: string) {
|
|
||||||
ficheEcoleOpen.value = false
|
|
||||||
const ecole = penseesData.value?.ecoles.find(e => e.id === ecoleId)
|
|
||||||
chatbotAuteur.value = ecole?.label ?? null
|
|
||||||
if (contentView.value !== 'carte') showCarte()
|
|
||||||
else if (layoutMode.value === 'carte-full') layoutMode.value = 'split'
|
|
||||||
}
|
|
||||||
|
|
||||||
function onInterrogerRag(auteurId: string) {
|
|
||||||
ficheOpen.value = false
|
|
||||||
const auteur = penseesData.value?.auteurs.find(a => a.id === auteurId)
|
|
||||||
chatbotAuteur.value = auteur?.nom ?? null
|
|
||||||
if (contentView.value !== 'carte') showCarte()
|
|
||||||
else if (layoutMode.value === 'carte-full') layoutMode.value = 'split'
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.media-visuel {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
overflow: hidden;
|
|
||||||
min-height: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.layout-container {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
overflow: hidden;
|
|
||||||
min-height: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --- Slot carte --- */
|
|
||||||
.carte-slot {
|
|
||||||
overflow: hidden;
|
|
||||||
position: relative;
|
|
||||||
transition: opacity 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.carte-split {
|
|
||||||
flex: 0 0 66%;
|
|
||||||
min-height: 0;
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.carte-full {
|
|
||||||
flex: 1 1 100%;
|
|
||||||
min-height: 0;
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.carte-hidden {
|
|
||||||
flex: 0 0 0;
|
|
||||||
height: 0;
|
|
||||||
opacity: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --- Overlay PDF FRACAS --- */
|
|
||||||
.fracas-overlay {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
z-index: 50;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --- Barre de toggle (entre carte et chatbot) --- */
|
|
||||||
.layout-toggle-bar {
|
|
||||||
flex-shrink: 0;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
padding: 4px 12px;
|
|
||||||
background: var(--nav-bg);
|
|
||||||
border-top: 1px solid rgba(180, 170, 160, 0.22);
|
|
||||||
border-bottom: 1px solid rgba(180, 170, 160, 0.22);
|
|
||||||
min-height: 38px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle-btn {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 5px;
|
|
||||||
padding: 4px 10px;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-weight: 500;
|
|
||||||
cursor: pointer;
|
|
||||||
background: var(--nav-bg-alt);
|
|
||||||
color: var(--nav-text-muted);
|
|
||||||
border: 1px solid transparent;
|
|
||||||
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle-btn:hover {
|
|
||||||
background: var(--nav-surface);
|
|
||||||
color: var(--nav-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle-btn.active {
|
|
||||||
background: var(--nav-primary);
|
|
||||||
color: var(--nav-text-on-primary);
|
|
||||||
border-color: var(--nav-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --- Contrôle fusionné carte des pensées --- */
|
|
||||||
.carte-pensees-ctrl {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0;
|
|
||||||
border-radius: 6px;
|
|
||||||
overflow: hidden;
|
|
||||||
border: 1px solid rgba(180, 170, 160, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.fracas-check {
|
|
||||||
margin: 0 2px 0 7px;
|
|
||||||
cursor: pointer;
|
|
||||||
accent-color: var(--nav-primary, #3b6ea5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.carte-pensees-btn {
|
|
||||||
border-radius: 0;
|
|
||||||
border: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.opacity-slider {
|
|
||||||
width: 80px;
|
|
||||||
cursor: pointer;
|
|
||||||
accent-color: var(--nav-primary, #3b6ea5);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --- Poignee draggable --- */
|
|
||||||
.split-handle {
|
|
||||||
flex-shrink: 0;
|
|
||||||
height: 8px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
cursor: row-resize;
|
|
||||||
background: transparent;
|
|
||||||
position: relative;
|
|
||||||
z-index: 10;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.split-handle:hover {
|
|
||||||
background: rgba(180, 170, 160, 0.18);
|
|
||||||
}
|
|
||||||
|
|
||||||
.split-handle-grip {
|
|
||||||
display: block;
|
|
||||||
width: 32px;
|
|
||||||
height: 4px;
|
|
||||||
border-radius: 2px;
|
|
||||||
background: repeating-linear-gradient(
|
|
||||||
to bottom,
|
|
||||||
rgba(160, 150, 140, 0.55) 0px,
|
|
||||||
rgba(160, 150, 140, 0.55) 1px,
|
|
||||||
transparent 1px,
|
|
||||||
transparent 3px
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 767px) {
|
|
||||||
.split-handle { display: none; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --- Slot chatbot --- */
|
|
||||||
.chatbot-slot {
|
|
||||||
overflow: hidden;
|
|
||||||
position: relative;
|
|
||||||
transition: opacity 0.2s ease;
|
|
||||||
border-top: 1px solid rgba(180, 170, 160, 0.28);
|
|
||||||
}
|
|
||||||
|
|
||||||
.chatbot-split {
|
|
||||||
flex: 1 1 0;
|
|
||||||
min-height: 120px;
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chatbot-full-mode {
|
|
||||||
flex: 1 1 100%;
|
|
||||||
min-height: 0;
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chatbot-hidden {
|
|
||||||
flex: 0 0 0;
|
|
||||||
height: 0;
|
|
||||||
opacity: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --- Transitions modal RAG info --- */
|
|
||||||
.backdrop-enter-active,.backdrop-leave-active { transition: opacity 0.2s; }
|
|
||||||
.backdrop-enter-from,.backdrop-leave-to { opacity: 0; }
|
|
||||||
.modal-enter-active { transition: opacity 0.2s, transform 0.22s cubic-bezier(0.34,1.56,0.64,1); }
|
|
||||||
.modal-leave-active { transition: opacity 0.18s, transform 0.18s ease-in; }
|
|
||||||
.modal-enter-from { opacity: 0; transform: translate(-50%,-48%) scale(0.94); }
|
|
||||||
.modal-leave-to { opacity: 0; transform: translate(-50%,-48%) scale(0.96); }
|
|
||||||
|
|
||||||
/* --- Mobile --- */
|
|
||||||
@media (max-width: 767px) {
|
|
||||||
.carte-split {
|
|
||||||
flex: 0 0 60vh;
|
|
||||||
height: 60vh;
|
|
||||||
}
|
|
||||||
.chatbot-split {
|
|
||||||
flex: 1 1 0;
|
|
||||||
min-height: 120px;
|
|
||||||
}
|
|
||||||
.toggle-btn {
|
|
||||||
font-size: 0.7rem;
|
|
||||||
padding: 3px 7px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,186 +0,0 @@
|
|||||||
<template>
|
|
||||||
<Teleport to="body">
|
|
||||||
<Transition name="backdrop">
|
|
||||||
<div
|
|
||||||
v-if="modelValue"
|
|
||||||
class="fixed inset-0 z-[1500]"
|
|
||||||
style="background: rgba(26,34,56,0.55);"
|
|
||||||
@click="close"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
</Transition>
|
|
||||||
|
|
||||||
<Transition name="modal">
|
|
||||||
<div
|
|
||||||
v-if="modelValue"
|
|
||||||
class="mission-modal"
|
|
||||||
role="dialog"
|
|
||||||
aria-modal="true"
|
|
||||||
aria-labelledby="mission-title"
|
|
||||||
@keydown.esc="close"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
class="mission-close"
|
|
||||||
type="button"
|
|
||||||
@click="close"
|
|
||||||
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>
|
|
||||||
|
|
||||||
<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é.
|
|
||||||
</p>
|
|
||||||
<p class="mission-text">
|
|
||||||
Cette carte est née de cette frustration — et de cette conviction : les ressources existent, les gens qui ont réussi à sortir la tête de l'eau aussi. L'enjeu, c'est de les documenter, de les rendre accessibles, de les ajuster en temps réel grâce aux retours de la communauté. Pas un catalogue figé ; un commun vivant, au service de ceux et celles qui cherchent à faire évoluer leur pratique vers quelque chose de plus épanouissant, mieux rémunéré, au service de la société — et qui prend soin de la santé, la nôtre et celle des gens pour qui nous construisons.
|
|
||||||
</p>
|
|
||||||
</slot>
|
|
||||||
|
|
||||||
<div class="mission-cta-wrap">
|
|
||||||
<button class="btn-explorer" type="button" @click="close">{{ ctaLabel }}</button>
|
|
||||||
<NuxtLink to="/manifeste" class="link-manifeste" @click="close">Lire le manifeste →</NuxtLink>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Transition>
|
|
||||||
</Teleport>
|
|
||||||
</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',
|
|
||||||
storageKey: 'aep_mission_seen',
|
|
||||||
})
|
|
||||||
|
|
||||||
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
|
|
||||||
|
|
||||||
function close() {
|
|
||||||
emit('update:modelValue', false)
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
try { localStorage.setItem(props.storageKey, '1') } catch {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.mission-modal {
|
|
||||||
position: fixed;
|
|
||||||
z-index: 1501;
|
|
||||||
top: 50%;
|
|
||||||
left: 50%;
|
|
||||||
transform: translate(-50%, -50%);
|
|
||||||
width: min(560px, 92vw);
|
|
||||||
max-height: calc(100dvh - 80px);
|
|
||||||
background: var(--nav-bg);
|
|
||||||
border-radius: 16px;
|
|
||||||
box-shadow: 0 16px 64px rgba(26,34,56,0.28);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mission-close {
|
|
||||||
position: absolute;
|
|
||||||
top: 12px;
|
|
||||||
right: 12px;
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
border-radius: 8px;
|
|
||||||
border: none;
|
|
||||||
background: var(--nav-bg-alt);
|
|
||||||
color: var(--nav-text-muted);
|
|
||||||
cursor: pointer;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
transition: background 0.15s;
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
.mission-close:hover { background: var(--nav-surface); }
|
|
||||||
|
|
||||||
.mission-body {
|
|
||||||
padding: 1.75rem 1.5rem 1.5rem;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mission-title {
|
|
||||||
font-size: 1.25rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--nav-text);
|
|
||||||
margin: 0 0 1rem;
|
|
||||||
line-height: 1.25;
|
|
||||||
padding-right: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mission-text,
|
|
||||||
:slotted(.mission-text) {
|
|
||||||
font-size: 0.95rem;
|
|
||||||
line-height: 1.65;
|
|
||||||
color: var(--nav-text);
|
|
||||||
margin: 0 0 1rem;
|
|
||||||
}
|
|
||||||
:slotted(.mission-text strong) { font-weight: 700; }
|
|
||||||
:slotted(.mission-text a) { color: var(--nav-primary-solid); text-decoration: underline; }
|
|
||||||
|
|
||||||
.mission-cta-wrap {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
align-items: center;
|
|
||||||
gap: 1rem;
|
|
||||||
margin-top: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-explorer {
|
|
||||||
padding: 0.65rem 1.25rem;
|
|
||||||
background: var(--nav-primary);
|
|
||||||
color: var(--nav-text-on-primary);
|
|
||||||
border: none;
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: opacity 0.15s;
|
|
||||||
}
|
|
||||||
.btn-explorer:hover { opacity: 0.88; }
|
|
||||||
|
|
||||||
.link-manifeste {
|
|
||||||
font-size: 0.875rem;
|
|
||||||
color: var(--nav-primary-solid);
|
|
||||||
text-decoration: underline;
|
|
||||||
text-underline-offset: 2px;
|
|
||||||
}
|
|
||||||
.link-manifeste:hover { opacity: 0.75; }
|
|
||||||
|
|
||||||
.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%); }
|
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
|
||||||
.mission-body { padding: 1.5rem 1.1rem 1.25rem; }
|
|
||||||
.mission-title { font-size: 1.1rem; }
|
|
||||||
.mission-text { font-size: 0.9rem; }
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
.modal-enter-active, .modal-leave-active { transition: none; }
|
|
||||||
.backdrop-enter-active, .backdrop-leave-active { transition: none; }
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -5,7 +5,6 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
const baseTiles = useBaseTiles()
|
|
||||||
import type { Map, Marker, DivIcon } from 'leaflet'
|
import type { Map, Marker, DivIcon } from 'leaflet'
|
||||||
|
|
||||||
interface Org {
|
interface Org {
|
||||||
@@ -91,10 +90,12 @@ async function initMap() {
|
|||||||
|
|
||||||
// Fond de carte CartoDB Positron (light ou dark selon theme)
|
// Fond de carte CartoDB Positron (light ou dark selon theme)
|
||||||
const isDark = typeof document !== 'undefined' && document.documentElement.classList.contains('dark')
|
const isDark = typeof document !== 'undefined' && document.documentElement.classList.contains('dark')
|
||||||
const tileUrl = baseTiles.url(isDark)
|
const tileUrl = isDark
|
||||||
|
? 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'
|
||||||
|
: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png'
|
||||||
|
|
||||||
tileLayerInstance = L.tileLayer(tileUrl, {
|
tileLayerInstance = L.tileLayer(tileUrl, {
|
||||||
attribution: baseTiles.attribution,
|
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors © <a href="https://carto.com/attributions">CARTO</a>',
|
||||||
maxZoom: 19,
|
maxZoom: 19,
|
||||||
})
|
})
|
||||||
tileLayerInstance.addTo(mapInstance!)
|
tileLayerInstance.addTo(mapInstance!)
|
||||||
@@ -211,19 +212,16 @@ function updateTileTheme(dark: boolean) {
|
|||||||
if (!mapInstance || !tileLayerInstance) return
|
if (!mapInstance || !tileLayerInstance) return
|
||||||
const L = (window as any).L
|
const L = (window as any).L
|
||||||
if (!L) return
|
if (!L) return
|
||||||
const url = baseTiles.url(dark)
|
const url = dark
|
||||||
|
? 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'
|
||||||
|
: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png'
|
||||||
tileLayerInstance.setUrl(url)
|
tileLayerInstance.setUrl(url)
|
||||||
}
|
}
|
||||||
|
|
||||||
let themeObserver: MutationObserver | null = null
|
let themeObserver: MutationObserver | null = null
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
// Double rAF : laisser le browser calculer la hauteur du conteneur avant Leaflet
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
initMap()
|
initMap()
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// Observer les changements de classe dark sur <html>
|
// Observer les changements de classe dark sur <html>
|
||||||
themeObserver = new MutationObserver(() => {
|
themeObserver = new MutationObserver(() => {
|
||||||
|
|||||||
@@ -125,8 +125,8 @@
|
|||||||
<span
|
<span
|
||||||
v-for="fn in orgFonctions(org)"
|
v-for="fn in orgFonctions(org)"
|
||||||
:key="fn"
|
:key="fn"
|
||||||
class="px-2 py-0.5 rounded-full text-xs font-medium"
|
class="px-1.5 py-0.5 rounded text-xs"
|
||||||
style="background: var(--nav-bg-alt); color: var(--nav-text-muted); border: 1px solid var(--nav-bg-alt); letter-spacing: 0.01em;"
|
style="background: var(--nav-bg-alt); color: var(--nav-text-muted);"
|
||||||
>{{ fn }}</span>
|
>{{ fn }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="org.localisation_ville" class="mt-0.5 text-xs" style="color: var(--nav-text-muted);">
|
<div v-if="org.localisation_ville" class="mt-0.5 text-xs" style="color: var(--nav-text-muted);">
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
<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>
|
||||||
@@ -1,150 +0,0 @@
|
|||||||
<template>
|
|
||||||
<component
|
|
||||||
:is="url ? 'a' : 'div'"
|
|
||||||
v-bind="url ? { href: url, target: '_blank', rel: 'noopener noreferrer' } : {}"
|
|
||||||
class="outil-card"
|
|
||||||
:class="{ 'outil-card--link': !!url, 'outil-card--disabled': !url }"
|
|
||||||
>
|
|
||||||
<div class="outil-card__header">
|
|
||||||
<span class="outil-card__icon" aria-hidden="true">{{ icon }}</span>
|
|
||||||
<span :class="['outil-card__badge', `outil-card__badge--${tag}`]">{{ tagLabel }}</span>
|
|
||||||
</div>
|
|
||||||
<h3 class="outil-card__titre">{{ titre }}</h3>
|
|
||||||
<p class="outil-card__desc">{{ description }}</p>
|
|
||||||
<span v-if="cta && url" class="outil-card__cta">{{ cta }}</span>
|
|
||||||
<span v-else-if="!url" class="outil-card__cta outil-card__cta--disabled">Bientôt disponible</span>
|
|
||||||
</component>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
const props = defineProps<{
|
|
||||||
icon?: string
|
|
||||||
titre: string
|
|
||||||
url?: string | null
|
|
||||||
description?: string
|
|
||||||
cta?: string
|
|
||||||
tag?: string
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const tagLabels: Record<string, string> = {
|
|
||||||
'outil-aep': 'Outil AEP',
|
|
||||||
'inspiration-externe': 'Inspiration',
|
|
||||||
'disponible': 'Disponible',
|
|
||||||
'recommande': 'Recommandé',
|
|
||||||
'a-venir': 'À venir',
|
|
||||||
}
|
|
||||||
|
|
||||||
const tagLabel = computed(() => props.tag ? (tagLabels[props.tag] ?? props.tag) : '')
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.outil-card {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.5rem;
|
|
||||||
padding: 1rem 1.25rem;
|
|
||||||
border-radius: 10px;
|
|
||||||
border: 1px solid var(--nav-bg-alt);
|
|
||||||
background: var(--nav-surface);
|
|
||||||
text-decoration: none;
|
|
||||||
color: var(--nav-text);
|
|
||||||
transition: box-shadow 0.15s, border-color 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.outil-card--link:hover {
|
|
||||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
|
|
||||||
border-color: var(--nav-primary-solid);
|
|
||||||
}
|
|
||||||
|
|
||||||
.outil-card--disabled {
|
|
||||||
opacity: 0.65;
|
|
||||||
}
|
|
||||||
|
|
||||||
.outil-card__header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.outil-card__icon {
|
|
||||||
font-size: 1.3rem;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.outil-card__badge {
|
|
||||||
font-size: 0.65rem;
|
|
||||||
font-weight: 600;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
padding: 2px 7px;
|
|
||||||
border-radius: 999px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.outil-card__badge--outil-aep {
|
|
||||||
background: #d1fae5;
|
|
||||||
color: #065f46;
|
|
||||||
}
|
|
||||||
.outil-card__badge--inspiration-externe {
|
|
||||||
background: #fef3c7;
|
|
||||||
color: #92400e;
|
|
||||||
}
|
|
||||||
.outil-card__badge--disponible {
|
|
||||||
background: #d1fae5;
|
|
||||||
color: #065f46;
|
|
||||||
}
|
|
||||||
.outil-card__badge--recommande {
|
|
||||||
background: #dbeafe;
|
|
||||||
color: #1e40af;
|
|
||||||
}
|
|
||||||
.outil-card__badge--a-venir {
|
|
||||||
background: var(--nav-bg-alt);
|
|
||||||
color: var(--nav-text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.outil-card__titre {
|
|
||||||
font-size: 0.9rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--nav-text);
|
|
||||||
margin: 0;
|
|
||||||
line-height: 1.35;
|
|
||||||
}
|
|
||||||
|
|
||||||
.outil-card__desc {
|
|
||||||
font-size: 0.82rem;
|
|
||||||
color: var(--nav-text-muted);
|
|
||||||
margin: 0;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.outil-card__cta {
|
|
||||||
font-size: 0.78rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--nav-primary-solid);
|
|
||||||
margin-top: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.outil-card__cta--disabled {
|
|
||||||
color: var(--nav-text-muted);
|
|
||||||
font-weight: 400;
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Dark mode badge overrides */
|
|
||||||
:global(.dark) .outil-card__badge--outil-aep {
|
|
||||||
background: #064e3b;
|
|
||||||
color: #a7f3d0;
|
|
||||||
}
|
|
||||||
:global(.dark) .outil-card__badge--inspiration-externe {
|
|
||||||
background: #78350f;
|
|
||||||
color: #fde68a;
|
|
||||||
}
|
|
||||||
:global(.dark) .outil-card__badge--disponible {
|
|
||||||
background: #064e3b;
|
|
||||||
color: #a7f3d0;
|
|
||||||
}
|
|
||||||
:global(.dark) .outil-card__badge--recommande {
|
|
||||||
background: #1e3a5f;
|
|
||||||
color: #93c5fd;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -38,7 +38,6 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
const baseTiles = useBaseTiles()
|
|
||||||
import type { Map as LeafletMap, TileLayer } from 'leaflet'
|
import type { Map as LeafletMap, TileLayer } from 'leaflet'
|
||||||
|
|
||||||
interface Org {
|
interface Org {
|
||||||
@@ -112,7 +111,9 @@ function createPinIcon(L: any, isPrioritaire: boolean, isSelected = false) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getTileUrl(dark: boolean) {
|
function getTileUrl(dark: boolean) {
|
||||||
return baseTiles.url(dark)
|
return dark
|
||||||
|
? 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'
|
||||||
|
: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png'
|
||||||
}
|
}
|
||||||
|
|
||||||
async function initSingleMap(domName: string) {
|
async function initSingleMap(domName: string) {
|
||||||
@@ -131,7 +132,7 @@ async function initSingleMap(domName: string) {
|
|||||||
touchZoom: true, keyboard: false,
|
touchZoom: true, keyboard: false,
|
||||||
})
|
})
|
||||||
const tileLayer = L.tileLayer(getTileUrl(isDark), {
|
const tileLayer = L.tileLayer(getTileUrl(isDark), {
|
||||||
attribution: baseTiles.attribution, maxZoom: 19,
|
attribution: '© OpenStreetMap contributors © CARTO', maxZoom: 19,
|
||||||
})
|
})
|
||||||
tileLayer.addTo(map)
|
tileLayer.addTo(map)
|
||||||
tileLayers[domName] = tileLayer as unknown as TileLayer
|
tileLayers[domName] = tileLayer as unknown as TileLayer
|
||||||
|
|||||||
@@ -0,0 +1,276 @@
|
|||||||
|
<template>
|
||||||
|
<div class="outremer-accordion">
|
||||||
|
<div
|
||||||
|
v-for="dom in DOM_TOM_PRATIQUES"
|
||||||
|
:key="dom.code"
|
||||||
|
class="outremer-item"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="outremer-header"
|
||||||
|
@click="toggle(dom.code)"
|
||||||
|
:aria-expanded="openDom === dom.code"
|
||||||
|
>
|
||||||
|
<span class="outremer-title">{{ dom.label }}</span>
|
||||||
|
<span class="outremer-meta">
|
||||||
|
<span class="outremer-count-badge" :style="orgCounts[dom.code] === 0 ? 'opacity:0.4' : ''">
|
||||||
|
{{ orgCounts[dom.code] ?? 0 }} fiche{{ (orgCounts[dom.code] ?? 0) > 1 ? 's' : '' }}
|
||||||
|
</span>
|
||||||
|
<svg
|
||||||
|
width="14" height="14" viewBox="0 0 24 24" fill="none"
|
||||||
|
stroke="currentColor" stroke-width="2.5" stroke-linecap="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
class="outremer-chevron"
|
||||||
|
:class="{ 'outremer-chevron--open': openDom === dom.code }"
|
||||||
|
>
|
||||||
|
<polyline points="6 9 12 15 18 9"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-show="openDom === dom.code"
|
||||||
|
class="outremer-map-container"
|
||||||
|
>
|
||||||
|
<div :ref="el => { if (el) mapRefs[dom.code] = el as HTMLElement }" class="outremer-map" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { Map as LeafletMap, TileLayer } from 'leaflet'
|
||||||
|
|
||||||
|
interface Pratique {
|
||||||
|
id: number
|
||||||
|
nom: string
|
||||||
|
lat?: number | null
|
||||||
|
lng?: number | null
|
||||||
|
pays?: string
|
||||||
|
ville?: string
|
||||||
|
type?: string
|
||||||
|
score?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const DOM_TOM_PRATIQUES = [
|
||||||
|
{ code: 'GP', label: 'Guadeloupe', center: [16.25, -61.58] as [number, number], zoom: 9 },
|
||||||
|
{ code: 'MQ', label: 'Martinique', center: [14.65, -61.02] as [number, number], zoom: 9 },
|
||||||
|
{ code: 'GF', label: 'Guyane', center: [4.0, -53.0] as [number, number], zoom: 6 },
|
||||||
|
{ code: 'RE', label: 'La Réunion', center: [-21.11, 55.53] as [number, number], zoom: 9 },
|
||||||
|
{ code: 'YT', label: 'Mayotte', center: [-12.83, 45.16] as [number, number], zoom: 10 },
|
||||||
|
{ code: 'PF', label: 'Polynésie française', center: [-17.5, -149.5] as [number, number], zoom: 8 },
|
||||||
|
{ code: 'NC', label: 'Nouvelle-Calédonie', center: [-20.9, 165.6] as [number, number], zoom: 7 },
|
||||||
|
]
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
orgs: Pratique[]
|
||||||
|
selectedId?: number | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'select-org': [id: number]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const mapRefs: Record<string, HTMLElement> = {}
|
||||||
|
const mapInstances: Record<string, LeafletMap> = {}
|
||||||
|
const tileLayers: Record<string, TileLayer> = {}
|
||||||
|
|
||||||
|
const openDom = ref<string | null>(null)
|
||||||
|
|
||||||
|
const orgCounts = computed<Record<string, number>>(() => {
|
||||||
|
const counts: Record<string, number> = {}
|
||||||
|
DOM_TOM_PRATIQUES.forEach(d => { counts[d.code] = 0 })
|
||||||
|
props.orgs.forEach(o => {
|
||||||
|
if (o.pays && counts[o.pays] !== undefined) {
|
||||||
|
counts[o.pays]++
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return counts
|
||||||
|
})
|
||||||
|
|
||||||
|
function toggle(code: string) {
|
||||||
|
openDom.value = openDom.value === code ? null : code
|
||||||
|
nextTick(() => {
|
||||||
|
if (openDom.value === code && !mapInstances[code]) {
|
||||||
|
initSingleMap(code)
|
||||||
|
} else if (openDom.value === code) {
|
||||||
|
mapInstances[code]?.invalidateSize()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPinIcon(L: any, score: number, isSelected = false) {
|
||||||
|
const bg = score >= 4 ? '#f5b342' : 'rgba(26, 34, 56, 0.6)'
|
||||||
|
const border = isSelected ? '#f5b342' : '#ffffff'
|
||||||
|
const size = isSelected ? 16 : 12
|
||||||
|
return L.divIcon({
|
||||||
|
className: '',
|
||||||
|
html: `<div style="width:${size}px;height:${size}px;border-radius:50%;background:${bg};border:2px solid ${border};"></div>`,
|
||||||
|
iconSize: [size, size],
|
||||||
|
iconAnchor: [size / 2, size / 2],
|
||||||
|
popupAnchor: [0, -(size / 2 + 4)],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTileUrl(dark: boolean) {
|
||||||
|
return dark
|
||||||
|
? 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'
|
||||||
|
: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initSingleMap(code: string) {
|
||||||
|
const dom = DOM_TOM_PRATIQUES.find(d => d.code === code)
|
||||||
|
if (!dom) return
|
||||||
|
const Lmod = await import('leaflet')
|
||||||
|
const L: any = (Lmod as any).default || Lmod
|
||||||
|
await import('leaflet/dist/leaflet.css')
|
||||||
|
const isDark = typeof document !== 'undefined' && document.documentElement.classList.contains('dark')
|
||||||
|
const el = mapRefs[code]
|
||||||
|
if (!el) return
|
||||||
|
const map = L.map(el, {
|
||||||
|
center: dom.center, zoom: dom.zoom,
|
||||||
|
zoomControl: false, attributionControl: false,
|
||||||
|
dragging: true, scrollWheelZoom: true, doubleClickZoom: true,
|
||||||
|
touchZoom: true, keyboard: false,
|
||||||
|
})
|
||||||
|
const tileLayer = L.tileLayer(getTileUrl(isDark), {
|
||||||
|
attribution: '© OpenStreetMap contributors © CARTO', maxZoom: 19,
|
||||||
|
})
|
||||||
|
tileLayer.addTo(map)
|
||||||
|
tileLayers[code] = tileLayer as unknown as TileLayer
|
||||||
|
mapInstances[code] = map as unknown as LeafletMap
|
||||||
|
renderPins(L, code)
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateTheme(dark: boolean) {
|
||||||
|
const url = getTileUrl(dark)
|
||||||
|
Object.values(tileLayers).forEach(tl => {
|
||||||
|
(tl as any).setUrl(url)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPins(L: any, code: string) {
|
||||||
|
const map = mapInstances[code] as any
|
||||||
|
if (!map) return
|
||||||
|
|
||||||
|
if (map._navMarkers) {
|
||||||
|
map._navMarkers.forEach((m: any) => m.remove())
|
||||||
|
}
|
||||||
|
map._navMarkers = []
|
||||||
|
|
||||||
|
const domOrgs = props.orgs.filter(o => o.pays === code && o.lat != null && o.lng != null)
|
||||||
|
domOrgs.forEach(org => {
|
||||||
|
const icon = createPinIcon(L, org.score ?? 1, org.id === props.selectedId)
|
||||||
|
const marker = L.marker([org.lat!, org.lng!], { icon })
|
||||||
|
|
||||||
|
marker.bindPopup(`
|
||||||
|
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;min-width:160px;padding:4px 0;">
|
||||||
|
<div style="font-weight:700;color:#1a2238;margin-bottom:2px;">${org.nom}</div>
|
||||||
|
${org.ville ? `<div style="font-size:11px;color:rgba(26,34,56,0.55);">${org.ville}</div>` : ''}
|
||||||
|
${org.type ? `<div style="font-size:11px;color:rgba(26,34,56,0.55);margin-top:2px;">${org.type}</div>` : ''}
|
||||||
|
<a href="/pratique/${org.id}" style="display:inline-block;margin-top:8px;font-size:12px;color:#1a2238;text-decoration:underline;">Voir la fiche →</a>
|
||||||
|
</div>
|
||||||
|
`, { maxWidth: 200 })
|
||||||
|
|
||||||
|
marker.on('click', () => emit('select-org', org.id))
|
||||||
|
marker.addTo(map)
|
||||||
|
map._navMarkers.push(marker)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => props.orgs, () => {
|
||||||
|
DOM_TOM_PRATIQUES.forEach(dom => {
|
||||||
|
if (mapInstances[dom.code]) {
|
||||||
|
import('leaflet').then(L => renderPins(L, dom.code))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, { deep: false })
|
||||||
|
|
||||||
|
watch(() => props.selectedId, () => {
|
||||||
|
DOM_TOM_PRATIQUES.forEach(dom => {
|
||||||
|
if (mapInstances[dom.code]) {
|
||||||
|
import('leaflet').then(L => renderPins(L, dom.code))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
let themeObserver: MutationObserver | null = null
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
themeObserver = new MutationObserver(() => {
|
||||||
|
const dark = document.documentElement.classList.contains('dark')
|
||||||
|
updateTheme(dark)
|
||||||
|
})
|
||||||
|
themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] })
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
themeObserver?.disconnect()
|
||||||
|
Object.values(mapInstances).forEach(m => (m as any).remove())
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.outremer-accordion {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.outremer-item {
|
||||||
|
border-bottom: 1px solid var(--nav-bg-alt);
|
||||||
|
}
|
||||||
|
|
||||||
|
.outremer-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: var(--nav-surface);
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.outremer-header:hover {
|
||||||
|
background: var(--nav-bg-alt);
|
||||||
|
}
|
||||||
|
|
||||||
|
.outremer-title {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--nav-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.outremer-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.outremer-count-badge {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--nav-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.outremer-chevron {
|
||||||
|
color: var(--nav-text-muted);
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.outremer-chevron--open {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.outremer-map-container {
|
||||||
|
height: 220px;
|
||||||
|
border-top: 1px solid var(--nav-bg-alt);
|
||||||
|
}
|
||||||
|
|
||||||
|
.outremer-map {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<!-- Groupe Europe -->
|
||||||
|
<span class="text-xs font-bold uppercase tracking-wide block mb-1.5" style="color: var(--nav-text-muted);">PAYS — EUROPE</span>
|
||||||
|
<div class="flex flex-wrap gap-1 mb-2">
|
||||||
|
<button
|
||||||
|
v-for="code in EUROPE_CODES"
|
||||||
|
:key="code"
|
||||||
|
type="button"
|
||||||
|
class="px-2 py-0.5 rounded-full text-xs transition-all"
|
||||||
|
:style="modelValue.includes(code)
|
||||||
|
? 'background: var(--nav-primary); color: var(--nav-text-on-primary); font-weight: 600;'
|
||||||
|
: 'background: var(--nav-bg-alt); color: var(--nav-text-muted);'"
|
||||||
|
@click="toggle(code)"
|
||||||
|
>
|
||||||
|
{{ PAYS_LABELS[code] ?? code }}
|
||||||
|
<span v-if="counts && counts[code] !== undefined" class="ml-1 opacity-60 text-xs">{{ counts[code] }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Groupe Outre-mer -->
|
||||||
|
<span class="text-xs font-bold uppercase tracking-wide block mb-1.5" style="color: var(--nav-text-muted);">OUTRE-MER</span>
|
||||||
|
<div class="flex flex-wrap gap-1">
|
||||||
|
<button
|
||||||
|
v-for="code in OUTREMER_CODES"
|
||||||
|
:key="code"
|
||||||
|
type="button"
|
||||||
|
class="px-2 py-0.5 rounded-full text-xs transition-all"
|
||||||
|
:style="modelValue.includes(code)
|
||||||
|
? 'background: var(--nav-primary); color: var(--nav-text-on-primary); font-weight: 600;'
|
||||||
|
: 'background: var(--nav-bg-alt); color: var(--nav-text-muted);'"
|
||||||
|
@click="toggle(code)"
|
||||||
|
>
|
||||||
|
{{ PAYS_LABELS[code] ?? code }}
|
||||||
|
<span v-if="counts && counts[code] !== undefined" class="ml-1 opacity-60 text-xs">{{ counts[code] }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { EUROPE_CODES, OUTREMER_CODES, PAYS_LABELS } from '~/types/pratique'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: string[]
|
||||||
|
counts?: Record<string, number>
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: string[]]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
function toggle(code: string) {
|
||||||
|
if (props.modelValue.includes(code)) {
|
||||||
|
emit('update:modelValue', props.modelValue.filter(v => v !== code))
|
||||||
|
} else {
|
||||||
|
emit('update:modelValue', [...props.modelValue, code])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -1,244 +0,0 @@
|
|||||||
<template>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="taff-card"
|
|
||||||
:style="`border-left-color: ${tagConfig.accent};`"
|
|
||||||
@click="$emit('open', plateforme)"
|
|
||||||
>
|
|
||||||
<!-- Ligne 1 : tag + badge AO + lien -->
|
|
||||||
<div class="taff-card-top">
|
|
||||||
<div class="flex items-center gap-2 flex-wrap">
|
|
||||||
<span class="taff-tag" :style="`background: ${tagConfig.bg}; color: ${tagConfig.text};`">
|
|
||||||
{{ tagConfig.emoji }} {{ tagConfig.label }}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
v-if="plateforme.type === 'appel-offre-public'"
|
|
||||||
class="taff-badge-ao"
|
|
||||||
>AO public</span>
|
|
||||||
</div>
|
|
||||||
<a
|
|
||||||
:href="plateforme.url"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
class="taff-visit-btn"
|
|
||||||
@click.stop
|
|
||||||
title="Visiter le site"
|
|
||||||
>
|
|
||||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
||||||
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
|
|
||||||
<polyline points="15 3 21 3 21 9"/>
|
|
||||||
<line x1="10" y1="14" x2="21" y2="3"/>
|
|
||||||
</svg>
|
|
||||||
Visiter
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Ligne 2 : nom -->
|
|
||||||
<div class="taff-card-name">{{ plateforme.nom }}</div>
|
|
||||||
|
|
||||||
<!-- Ligne 3 : axes (icône + score, compacts) -->
|
|
||||||
<div class="taff-card-axes">
|
|
||||||
<template v-for="axe in AXES" :key="axe.id">
|
|
||||||
<span
|
|
||||||
v-if="plateforme.scoring[axe.id] !== null"
|
|
||||||
class="taff-axe-chip"
|
|
||||||
:style="`background: ${axeScoreBg(plateforme.scoring[axe.id])}; color: ${axeScoreText(plateforme.scoring[axe.id])};`"
|
|
||||||
:title="axe.label"
|
|
||||||
>{{ axe.icon }} {{ plateforme.scoring[axe.id] }}</span>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Ligne 4 : description (3 lignes max, lisible) -->
|
|
||||||
<p class="taff-card-desc">{{ plateforme.description_courte }}</p>
|
|
||||||
|
|
||||||
<!-- Ligne 5 : secteurs + coût -->
|
|
||||||
<div class="taff-card-footer">
|
|
||||||
<div class="flex items-center gap-1.5 flex-wrap">
|
|
||||||
<span
|
|
||||||
v-for="s in plateforme.secteurs_servis.slice(0, 3)"
|
|
||||||
:key="s"
|
|
||||||
class="taff-secteur-chip"
|
|
||||||
>{{ SECTEUR_LABELS[s] ?? s }}</span>
|
|
||||||
<span v-if="plateforme.secteurs_servis.length > 3" class="taff-more">+{{ plateforme.secteurs_servis.length - 3 }}</span>
|
|
||||||
</div>
|
|
||||||
<span class="taff-cout">{{ COUT_LABELS[plateforme.cout_entree] ?? plateforme.cout_entree }}</span>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import type { PlateformeTaff } from '~/types/plateforme-taff'
|
|
||||||
|
|
||||||
const props = defineProps<{ plateforme: PlateformeTaff }>()
|
|
||||||
defineEmits<{ open: [p: PlateformeTaff] }>()
|
|
||||||
|
|
||||||
const AXES = [
|
|
||||||
{ id: 'remuneration' as const, icon: '🪙', label: 'Rémunération' },
|
|
||||||
{ id: 'transparence' as const, icon: '🔍', label: 'Transparence' },
|
|
||||||
{ id: 'pratiques' as const, icon: '⚖️', label: 'Pratiques pro' },
|
|
||||||
{ id: 'ecologie' as const, icon: '🌿', label: 'Écologie' },
|
|
||||||
{ id: 'matching' as const, icon: '🎯', label: 'Matching' },
|
|
||||||
]
|
|
||||||
|
|
||||||
const TAG_CONFIG = {
|
|
||||||
'recommande': { emoji: '✅', label: 'Recommandé AEP', accent: '#5a7a4a', bg: 'rgba(90,122,74,0.12)', text: '#3d5534' },
|
|
||||||
'sous-reserve': { emoji: '⚠️', label: 'Sous réserve', accent: '#c4a472', bg: 'rgba(196,164,114,0.15)', text: '#7a5f2a' },
|
|
||||||
'a-eviter': { emoji: '❌', label: 'À éviter', accent: '#a85d3e', bg: 'rgba(168,93,62,0.12)', text: '#7a3322' },
|
|
||||||
}
|
|
||||||
|
|
||||||
const tagConfig = computed(() => TAG_CONFIG[props.plateforme.scoring.tag_global] ?? TAG_CONFIG['sous-reserve'])
|
|
||||||
|
|
||||||
function axeScoreBg(score: string | null) {
|
|
||||||
if (score === '✅') return 'rgba(90,122,74,0.12)'
|
|
||||||
if (score === '⚠️') return 'rgba(196,164,114,0.15)'
|
|
||||||
if (score === '❌') return 'rgba(168,93,62,0.12)'
|
|
||||||
return 'var(--nav-bg-alt)'
|
|
||||||
}
|
|
||||||
function axeScoreText(score: string | null) {
|
|
||||||
if (score === '✅') return '#3d5534'
|
|
||||||
if (score === '⚠️') return '#7a5f2a'
|
|
||||||
if (score === '❌') return '#7a3322'
|
|
||||||
return 'var(--nav-text-muted)'
|
|
||||||
}
|
|
||||||
|
|
||||||
const SECTEUR_LABELS: Record<string, string> = {
|
|
||||||
'renovation': 'Rénovation', 'construction-neuve': 'Neuf', 'urbanisme': 'Urbanisme',
|
|
||||||
'architecture-interieure': 'Archi intérieure', 'paysage': 'Paysage',
|
|
||||||
'mar-conseil': 'MAR/Conseil', 'transversal': 'Transversal',
|
|
||||||
}
|
|
||||||
const COUT_LABELS: Record<string, string> = {
|
|
||||||
'gratuit': 'Gratuit', 'freemium': 'Freemium', 'abonnement': 'Abonnement',
|
|
||||||
'lead-paye': 'Lead payant', 'commission': 'Commission',
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.taff-card {
|
|
||||||
width: 100%;
|
|
||||||
text-align: left;
|
|
||||||
border-radius: 12px;
|
|
||||||
border: 1px solid var(--nav-bg-alt);
|
|
||||||
border-left: 4px solid;
|
|
||||||
background: var(--nav-surface);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
transition: box-shadow 0.2s;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.taff-card:hover { box-shadow: 0 4px 16px rgba(26,34,56,0.1); }
|
|
||||||
.taff-card:focus-visible { outline: 2px solid var(--nav-accent); outline-offset: 2px; }
|
|
||||||
|
|
||||||
.taff-card-top {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 0.5rem;
|
|
||||||
padding: 1rem 1rem 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.taff-tag {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.25rem;
|
|
||||||
padding: 0.25rem 0.625rem;
|
|
||||||
border-radius: 9999px;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.taff-badge-ao {
|
|
||||||
display: inline-flex;
|
|
||||||
padding: 0.25rem 0.5rem;
|
|
||||||
border-radius: 9999px;
|
|
||||||
font-size: 0.6875rem;
|
|
||||||
font-weight: 500;
|
|
||||||
background: var(--nav-bg-alt);
|
|
||||||
color: var(--nav-text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.taff-visit-btn {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.375rem;
|
|
||||||
padding: 0.375rem 0.75rem;
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-weight: 500;
|
|
||||||
background: var(--nav-bg-alt);
|
|
||||||
color: var(--nav-text);
|
|
||||||
white-space: nowrap;
|
|
||||||
flex-shrink: 0;
|
|
||||||
transition: opacity 0.15s;
|
|
||||||
}
|
|
||||||
.taff-visit-btn:hover { opacity: 0.7; }
|
|
||||||
|
|
||||||
.taff-card-name {
|
|
||||||
padding: 0.25rem 1rem 0.75rem;
|
|
||||||
font-size: 1.0625rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--nav-text);
|
|
||||||
line-height: 1.3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.taff-card-axes {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.375rem;
|
|
||||||
padding: 0 1rem 0.875rem;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.taff-axe-chip {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.25rem;
|
|
||||||
padding: 0.25rem 0.625rem;
|
|
||||||
border-radius: 9999px;
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.taff-card-desc {
|
|
||||||
padding: 0 1rem 1rem;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
line-height: 1.65;
|
|
||||||
color: var(--nav-text-muted);
|
|
||||||
display: -webkit-box;
|
|
||||||
-webkit-line-clamp: 3;
|
|
||||||
-webkit-box-orient: vertical;
|
|
||||||
overflow: hidden;
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.taff-card-footer {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 0.5rem;
|
|
||||||
padding: 0.75rem 1rem;
|
|
||||||
border-top: 1px solid var(--nav-bg-alt);
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.taff-secteur-chip {
|
|
||||||
display: inline-block;
|
|
||||||
padding: 0.2rem 0.5rem;
|
|
||||||
border-radius: 9999px;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
background: var(--nav-bg);
|
|
||||||
color: var(--nav-text-muted);
|
|
||||||
border: 1px solid var(--nav-bg-alt);
|
|
||||||
}
|
|
||||||
|
|
||||||
.taff-more {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: var(--nav-text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.taff-cout {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--nav-text-muted);
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
<template>
|
||||||
|
<aside
|
||||||
|
class="flex flex-col h-full overflow-hidden"
|
||||||
|
style="background: var(--nav-surface); border-right: 1px solid var(--nav-bg-alt);"
|
||||||
|
>
|
||||||
|
|
||||||
|
<!-- ═══════════════════════════════════ BARRE DE RECHERCHE -->
|
||||||
|
<div
|
||||||
|
class="shrink-0 px-4 pt-4 pb-3 border-b"
|
||||||
|
style="border-color: var(--nav-bg-alt);"
|
||||||
|
>
|
||||||
|
<label class="sidebar-search-label" aria-label="Rechercher une pratique">
|
||||||
|
<svg
|
||||||
|
width="15" height="15" viewBox="0 0 24 24"
|
||||||
|
fill="none" stroke="currentColor" stroke-width="2"
|
||||||
|
stroke-linecap="round" stroke-linejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
class="sidebar-search-icon"
|
||||||
|
>
|
||||||
|
<circle cx="11" cy="11" r="8"/>
|
||||||
|
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
|
||||||
|
</svg>
|
||||||
|
<input
|
||||||
|
ref="searchInputEl"
|
||||||
|
:value="search"
|
||||||
|
type="search"
|
||||||
|
placeholder="Rechercher une pratique…"
|
||||||
|
class="sidebar-search-input"
|
||||||
|
autocomplete="off"
|
||||||
|
@input="emit('update:search', ($event.target as HTMLInputElement).value)"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
v-if="search"
|
||||||
|
type="button"
|
||||||
|
class="sidebar-search-clear"
|
||||||
|
aria-label="Effacer la recherche"
|
||||||
|
@click.stop="emit('update:search', '')"
|
||||||
|
>
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18"/>
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ═══════════════════════════════════ FILTRES -->
|
||||||
|
<div
|
||||||
|
class="shrink-0 px-4 pt-3 pb-3 space-y-4 border-b overflow-y-auto"
|
||||||
|
style="border-color: var(--nav-bg-alt); max-height: 280px;"
|
||||||
|
>
|
||||||
|
<!-- Critères régé -->
|
||||||
|
<CritereFilter
|
||||||
|
:modelValue="criteres"
|
||||||
|
:counts="critereCount"
|
||||||
|
@update:modelValue="emit('update:criteres', $event)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Type entité -->
|
||||||
|
<TypeEntiteFilter
|
||||||
|
:modelValue="typesEntite"
|
||||||
|
:counts="typeCount"
|
||||||
|
@update:modelValue="emit('update:typesEntite', $event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ═══════════════════════════════════ LISTE FICHES -->
|
||||||
|
<div class="flex-1 flex flex-col min-h-0">
|
||||||
|
<div
|
||||||
|
class="shrink-0 flex items-center justify-between px-4 py-2 border-b"
|
||||||
|
style="border-color: var(--nav-bg-alt);"
|
||||||
|
>
|
||||||
|
<span class="text-xs font-bold uppercase tracking-widest" style="color: var(--nav-text-muted);">
|
||||||
|
{{ resultCount }} résultat{{ resultCount > 1 ? 's' : '' }}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
v-if="hasActiveFilters"
|
||||||
|
@click="emit('reset-filters')"
|
||||||
|
class="text-xs underline hover:opacity-70"
|
||||||
|
style="color: var(--nav-text-muted);"
|
||||||
|
>
|
||||||
|
Effacer les filtres
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-1 overflow-y-auto px-3 py-2 space-y-1.5">
|
||||||
|
<div
|
||||||
|
v-if="pending"
|
||||||
|
class="flex items-center justify-center py-8"
|
||||||
|
style="color: var(--nav-text-muted);"
|
||||||
|
>
|
||||||
|
Chargement…
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="pratiques.length === 0" class="text-center py-8">
|
||||||
|
<p class="text-xs" style="color: var(--nav-text-muted);">Aucun résultat</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-for="pratique in pratiques"
|
||||||
|
:key="pratique.id"
|
||||||
|
class="rounded-lg px-3 py-2 cursor-pointer transition-all"
|
||||||
|
:style="selectedId === pratique.id
|
||||||
|
? 'background: var(--nav-bg-alt); border-left: 3px solid var(--nav-accent); padding-left: 9px;'
|
||||||
|
: 'background: var(--nav-bg); border-left: 3px solid transparent; padding-left: 9px;'"
|
||||||
|
@click="emit('select-pratique', pratique.id)"
|
||||||
|
@mouseenter="emit('hover-pratique', pratique.id)"
|
||||||
|
@mouseleave="emit('hover-pratique', null)"
|
||||||
|
>
|
||||||
|
<div class="flex items-start justify-between gap-1.5">
|
||||||
|
<span class="font-semibold text-sm leading-snug" style="color: var(--nav-text);">{{ pratique.nom }}</span>
|
||||||
|
<span
|
||||||
|
v-if="pratique.pays"
|
||||||
|
class="shrink-0 px-1.5 py-0.5 rounded-full text-xs"
|
||||||
|
style="background: var(--nav-bg-alt); color: var(--nav-text-muted); margin-top: 1px;"
|
||||||
|
>{{ pratique.pays }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="pratique.criteres?.length" class="mt-1 flex flex-wrap gap-1">
|
||||||
|
<span
|
||||||
|
v-for="cId in pratique.criteres.slice(0, 3)"
|
||||||
|
:key="cId"
|
||||||
|
class="px-1.5 py-0.5 rounded text-xs"
|
||||||
|
style="background: var(--nav-bg-alt); color: var(--nav-text-muted);"
|
||||||
|
>{{ CRITERES.find(c => c.id === cId)?.label }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="pratique.ville" class="mt-0.5 text-xs" style="color: var(--nav-text-muted);">
|
||||||
|
{{ pratique.ville }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ═══════════════════════════════════ CTA PROPOSER -->
|
||||||
|
<div
|
||||||
|
class="shrink-0 px-4 py-3 border-t"
|
||||||
|
style="border-color: var(--nav-bg-alt);"
|
||||||
|
>
|
||||||
|
<NuxtLink
|
||||||
|
to="/proposer-pratique"
|
||||||
|
class="sidebar-cta-link"
|
||||||
|
>
|
||||||
|
+ Proposer une pratique
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</aside>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { CRITERES } from '~/types/pratique'
|
||||||
|
|
||||||
|
interface Pratique {
|
||||||
|
id: number
|
||||||
|
nom: string
|
||||||
|
pays?: string
|
||||||
|
ville?: string
|
||||||
|
type?: string
|
||||||
|
criteres?: number[]
|
||||||
|
score?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
search: string
|
||||||
|
criteres: number[]
|
||||||
|
typesEntite: string[]
|
||||||
|
critereCount: Record<number, number>
|
||||||
|
typeCount: Record<string, number>
|
||||||
|
resultCount: number
|
||||||
|
pratiques: Pratique[]
|
||||||
|
selectedId: number | null
|
||||||
|
hasActiveFilters: boolean
|
||||||
|
pending?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:search': [value: string]
|
||||||
|
'update:criteres': [value: number[]]
|
||||||
|
'update:typesEntite': [value: string[]]
|
||||||
|
'select-pratique': [id: number]
|
||||||
|
'hover-pratique': [id: number | null]
|
||||||
|
'reset-filters': []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const searchInputEl = ref<HTMLInputElement | null>(null)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.sidebar-search-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
border: 1.5px solid var(--nav-bg-alt);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--nav-bg);
|
||||||
|
padding: 7px 10px;
|
||||||
|
cursor: text;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-search-label:focus-within {
|
||||||
|
border-color: var(--nav-primary);
|
||||||
|
background: var(--nav-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-search-icon {
|
||||||
|
color: var(--nav-text-muted);
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-search-label:focus-within .sidebar-search-icon {
|
||||||
|
color: var(--nav-primary-solid);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-search-input {
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--nav-text);
|
||||||
|
font-size: 13px;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
font-family: var(--nav-font);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-search-input::placeholder {
|
||||||
|
color: var(--nav-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-search-input::-webkit-search-cancel-button {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-search-clear {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--nav-text-muted);
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 2px;
|
||||||
|
border-radius: 50%;
|
||||||
|
transition: color 0.15s, background 0.15s;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-search-clear:hover {
|
||||||
|
color: var(--nav-text);
|
||||||
|
background: var(--nav-bg-alt);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-cta-link {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--nav-primary-solid);
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--nav-primary-solid);
|
||||||
|
border-radius: 6px;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: background 0.15s, color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-cta-link:hover {
|
||||||
|
background: var(--nav-primary);
|
||||||
|
color: var(--nav-text-on-primary);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,156 +0,0 @@
|
|||||||
<template>
|
|
||||||
<component
|
|
||||||
:is="url ? 'a' : 'div'"
|
|
||||||
v-bind="url ? { href: url, target: '_blank', rel: 'noopener noreferrer' } : {}"
|
|
||||||
class="simu-feature"
|
|
||||||
:class="{ 'simu-feature--link': !!url }"
|
|
||||||
>
|
|
||||||
<div class="simu-feature__inner">
|
|
||||||
<div class="simu-feature__left">
|
|
||||||
<span class="simu-feature__icon" aria-hidden="true">{{ icon }}</span>
|
|
||||||
<div class="simu-feature__body">
|
|
||||||
<div class="simu-feature__header">
|
|
||||||
<h3 class="simu-feature__titre">{{ titre }}</h3>
|
|
||||||
<span v-if="tag" :class="['simu-feature__badge', `simu-feature__badge--${tag}`]">{{ tagLabel }}</span>
|
|
||||||
</div>
|
|
||||||
<p class="simu-feature__desc">{{ description }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<span v-if="cta && url" class="simu-feature__cta">{{ cta }}</span>
|
|
||||||
</div>
|
|
||||||
</component>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
const props = defineProps<{
|
|
||||||
icon?: string
|
|
||||||
titre: string
|
|
||||||
url?: string | null
|
|
||||||
description?: string
|
|
||||||
cta?: string
|
|
||||||
tag?: string
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const tagLabels: Record<string, string> = {
|
|
||||||
'outil-aep': 'Outil AEP',
|
|
||||||
'inspiration-externe': 'Inspiration externe',
|
|
||||||
'disponible': 'Disponible',
|
|
||||||
'recommande': 'Recommandé',
|
|
||||||
'a-venir': 'À venir',
|
|
||||||
}
|
|
||||||
|
|
||||||
const tagLabel = computed(() => props.tag ? (tagLabels[props.tag] ?? props.tag) : '')
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.simu-feature {
|
|
||||||
display: block;
|
|
||||||
padding: 1.5rem 1.75rem;
|
|
||||||
border-radius: 14px;
|
|
||||||
border: 1.5px solid var(--nav-bg-alt);
|
|
||||||
background: var(--nav-surface);
|
|
||||||
text-decoration: none;
|
|
||||||
color: var(--nav-text);
|
|
||||||
transition: box-shadow 0.2s, border-color 0.2s, transform 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.simu-feature--link:hover {
|
|
||||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
|
|
||||||
border-color: var(--nav-primary-solid);
|
|
||||||
transform: translateY(-2px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.simu-feature__inner {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 1.5rem;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.simu-feature__left {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 1rem;
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.simu-feature__icon {
|
|
||||||
font-size: 2rem;
|
|
||||||
line-height: 1;
|
|
||||||
flex-shrink: 0;
|
|
||||||
margin-top: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.simu-feature__body {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.simu-feature__header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.75rem;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
margin-bottom: 0.4rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.simu-feature__titre {
|
|
||||||
font-size: 1.05rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--nav-text);
|
|
||||||
margin: 0;
|
|
||||||
line-height: 1.3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.simu-feature__badge {
|
|
||||||
font-size: 0.65rem;
|
|
||||||
font-weight: 600;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
padding: 2px 8px;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: #d1fae5;
|
|
||||||
color: #065f46;
|
|
||||||
}
|
|
||||||
|
|
||||||
.simu-feature__badge--inspiration-externe {
|
|
||||||
background: #fef3c7;
|
|
||||||
color: #92400e;
|
|
||||||
}
|
|
||||||
|
|
||||||
.simu-feature__desc {
|
|
||||||
font-size: 0.88rem;
|
|
||||||
color: var(--nav-text-muted);
|
|
||||||
margin: 0;
|
|
||||||
line-height: 1.55;
|
|
||||||
}
|
|
||||||
|
|
||||||
.simu-feature__cta {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: 0.6rem 1.25rem;
|
|
||||||
background: var(--nav-primary-solid);
|
|
||||||
color: var(--nav-text-on-primary);
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
font-weight: 600;
|
|
||||||
white-space: nowrap;
|
|
||||||
transition: opacity 0.15s;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.simu-feature--link:hover .simu-feature__cta {
|
|
||||||
opacity: 0.88;
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.dark) .simu-feature__badge {
|
|
||||||
background: #064e3b;
|
|
||||||
color: #a7f3d0;
|
|
||||||
}
|
|
||||||
:global(.dark) .simu-feature__badge--inspiration-externe {
|
|
||||||
background: #78350f;
|
|
||||||
color: #fde68a;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<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>
|
||||||
@@ -1,201 +0,0 @@
|
|||||||
<template>
|
|
||||||
<ul class="tree-ascii" :class="{ 'tree-ascii--root': depth === 0 }" :style="{ '--depth': depth }">
|
|
||||||
<li
|
|
||||||
v-for="(node, i) in tree.children"
|
|
||||||
:key="i"
|
|
||||||
class="tree-ascii__node"
|
|
||||||
>
|
|
||||||
<!-- Nœud avec enfants : bouton toggle -->
|
|
||||||
<template v-if="node.children && node.children.length">
|
|
||||||
<button
|
|
||||||
class="tree-ascii__branch"
|
|
||||||
:aria-expanded="!!open[i]"
|
|
||||||
@click="toggle(i)"
|
|
||||||
>
|
|
||||||
<span class="tree-ascii__chevron" aria-hidden="true">{{ open[i] ? '▼' : '▶' }}</span>
|
|
||||||
<span class="tree-ascii__label">{{ node.name }}</span>
|
|
||||||
<span class="tree-ascii__count">({{ node.children.length }})</span>
|
|
||||||
</button>
|
|
||||||
<TreeASCII
|
|
||||||
v-if="open[i]"
|
|
||||||
:tree="node"
|
|
||||||
:depth="depth + 1"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- Feuille avec URL : lien cliquable -->
|
|
||||||
<template v-else-if="node.url">
|
|
||||||
<a
|
|
||||||
:href="node.url"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
class="tree-ascii__leaf tree-ascii__leaf--link"
|
|
||||||
>
|
|
||||||
<span class="tree-ascii__prefix" aria-hidden="true">└─</span>
|
|
||||||
<span class="tree-ascii__label">{{ node.name }}</span>
|
|
||||||
<span v-if="node.desc" class="tree-ascii__desc"> — {{ node.desc }}</span>
|
|
||||||
<svg class="tree-ascii__ext" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
||||||
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
|
|
||||||
<polyline points="15 3 21 3 21 9"/>
|
|
||||||
<line x1="10" y1="14" x2="21" y2="3"/>
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- Feuille sans URL -->
|
|
||||||
<template v-else>
|
|
||||||
<span class="tree-ascii__leaf">
|
|
||||||
<span class="tree-ascii__prefix" aria-hidden="true">└─</span>
|
|
||||||
<span class="tree-ascii__label">{{ node.name }}</span>
|
|
||||||
<span v-if="node.desc" class="tree-ascii__desc"> — {{ node.desc }}</span>
|
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
export interface TreeNode {
|
|
||||||
name: string
|
|
||||||
url?: string
|
|
||||||
desc?: string
|
|
||||||
children?: TreeNode[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TreeData {
|
|
||||||
name?: string
|
|
||||||
children?: TreeNode[]
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
|
||||||
tree: TreeData
|
|
||||||
depth?: number
|
|
||||||
}>(), {
|
|
||||||
depth: 0
|
|
||||||
})
|
|
||||||
|
|
||||||
// Toutes les branches fermées par défaut
|
|
||||||
const open = ref<Record<number, boolean>>({})
|
|
||||||
|
|
||||||
function toggle(i: number) {
|
|
||||||
open.value[i] = !open.value[i]
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.tree-ascii {
|
|
||||||
list-style: none;
|
|
||||||
padding: 0;
|
|
||||||
margin: 0;
|
|
||||||
font-family: 'JetBrains Mono', 'Fira Code', 'Courier New', monospace;
|
|
||||||
font-size: 0.82rem;
|
|
||||||
padding-left: calc(var(--depth, 0) * 1.25rem + 0.5rem);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tree-ascii--root {
|
|
||||||
padding-left: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tree-ascii__node {
|
|
||||||
margin: 2px 0;
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Bouton branche (nœud avec enfants) */
|
|
||||||
.tree-ascii__branch {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.35rem;
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 2px 6px;
|
|
||||||
border-radius: 4px;
|
|
||||||
color: var(--nav-text);
|
|
||||||
font-family: inherit;
|
|
||||||
font-size: inherit;
|
|
||||||
font-weight: 600;
|
|
||||||
transition: background 0.1s, color 0.1s;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tree-ascii__branch:hover {
|
|
||||||
background: var(--nav-bg-alt);
|
|
||||||
color: var(--nav-primary-solid);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tree-ascii__chevron {
|
|
||||||
font-size: 0.65rem;
|
|
||||||
color: var(--nav-text-muted);
|
|
||||||
width: 12px;
|
|
||||||
text-align: center;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tree-ascii__count {
|
|
||||||
font-size: 0.7rem;
|
|
||||||
color: var(--nav-text-muted);
|
|
||||||
font-weight: 400;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Feuille */
|
|
||||||
.tree-ascii__leaf {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: baseline;
|
|
||||||
gap: 0.25rem;
|
|
||||||
padding: 1px 6px;
|
|
||||||
border-radius: 4px;
|
|
||||||
text-decoration: none;
|
|
||||||
color: var(--nav-text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tree-ascii__leaf--link {
|
|
||||||
color: var(--nav-text);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 0.1s, color 0.1s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tree-ascii__leaf--link:hover {
|
|
||||||
background: var(--nav-bg-alt);
|
|
||||||
color: var(--nav-primary-solid);
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tree-ascii__prefix {
|
|
||||||
color: var(--nav-text-muted);
|
|
||||||
opacity: 0.5;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tree-ascii__label {
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tree-ascii__leaf--link .tree-ascii__label {
|
|
||||||
font-weight: 400;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tree-ascii__desc {
|
|
||||||
color: var(--nav-text-muted);
|
|
||||||
font-size: 0.78rem;
|
|
||||||
font-style: italic;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
max-width: 60ch;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tree-ascii__ext {
|
|
||||||
opacity: 0.4;
|
|
||||||
flex-shrink: 0;
|
|
||||||
margin-left: 2px;
|
|
||||||
vertical-align: middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
|
||||||
.tree-ascii__desc {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<span class="text-xs font-bold uppercase tracking-wide block mb-1.5" style="color: var(--nav-text-muted);">TYPE D'ENTITÉ</span>
|
||||||
|
<div class="flex flex-wrap gap-1">
|
||||||
|
<button
|
||||||
|
v-for="type in TYPES_ENTITE"
|
||||||
|
:key="type"
|
||||||
|
type="button"
|
||||||
|
class="px-2 py-0.5 rounded-full text-xs transition-all"
|
||||||
|
:style="modelValue.includes(type)
|
||||||
|
? 'background: var(--nav-primary); color: var(--nav-text-on-primary); font-weight: 600;'
|
||||||
|
: 'background: var(--nav-bg-alt); color: var(--nav-text-muted);'"
|
||||||
|
@click="toggle(type)"
|
||||||
|
>
|
||||||
|
{{ TYPES_ENTITE_LABELS[type] ?? type }}
|
||||||
|
<span v-if="counts && counts[type] !== undefined" class="ml-1 opacity-60 text-xs">{{ counts[type] }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { TYPES_ENTITE, TYPES_ENTITE_LABELS } from '~/types/pratique'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: string[]
|
||||||
|
counts?: Record<string, number>
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: string[]]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
function toggle(type: string) {
|
||||||
|
if (props.modelValue.includes(type)) {
|
||||||
|
emit('update:modelValue', props.modelValue.filter(v => v !== type))
|
||||||
|
} else {
|
||||||
|
emit('update:modelValue', [...props.modelValue, type])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -1,450 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div ref="container" class="codev-graph-wrap">
|
|
||||||
|
|
||||||
<!-- Placeholder si aucune fiche -->
|
|
||||||
<div v-if="fiches.length === 0" class="empty-state">
|
|
||||||
<p class="empty-msg">Encore personne. Sois la premiere fiche !</p>
|
|
||||||
<NuxtLink to="/codev/fiche" class="empty-link">Creer ma fiche →</NuxtLink>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- SVG D3 -->
|
|
||||||
<svg v-else ref="svgEl" class="codev-svg">
|
|
||||||
<defs>
|
|
||||||
<marker
|
|
||||||
id="arrow-solution"
|
|
||||||
viewBox="0 0 10 10"
|
|
||||||
refX="18"
|
|
||||||
refY="5"
|
|
||||||
markerWidth="6"
|
|
||||||
markerHeight="6"
|
|
||||||
orient="auto-start-reverse"
|
|
||||||
>
|
|
||||||
<path d="M 0 0 L 10 5 L 0 10 z" fill="#22c55e" />
|
|
||||||
</marker>
|
|
||||||
</defs>
|
|
||||||
</svg>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import * as d3 from 'd3'
|
|
||||||
import type { CodevFiche, CodevMatch } from '~/types/codev'
|
|
||||||
|
|
||||||
// ── Props / Emits ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
|
||||||
fiches: CodevFiche[]
|
|
||||||
matches?: CodevMatch[]
|
|
||||||
mode?: 'none' | 'solution' | 'alliance' | 'surprise'
|
|
||||||
showLabels?: boolean
|
|
||||||
}>(), {
|
|
||||||
matches: () => [],
|
|
||||||
mode: 'none',
|
|
||||||
showLabels: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
'select-fiche': [id: number]
|
|
||||||
}>()
|
|
||||||
|
|
||||||
// ── Refs ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const container = ref<HTMLDivElement | null>(null)
|
|
||||||
const svgEl = ref<SVGSVGElement | null>(null)
|
|
||||||
const width = ref(800)
|
|
||||||
const height = ref(600)
|
|
||||||
|
|
||||||
// ── State interne ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
type SimNode = d3.SimulationNodeDatum & { id: number; nom: string; offre: string; besoin: string }
|
|
||||||
type SimLink = d3.SimulationLinkDatum<SimNode> & { score: number; mode: string }
|
|
||||||
|
|
||||||
let simulation: d3.Simulation<SimNode, SimLink> | null = null
|
|
||||||
let svgRoot: d3.Selection<SVGSVGElement, unknown, null, undefined> | null = null
|
|
||||||
let gLinks: d3.Selection<SVGGElement, unknown, null, undefined> | null = null
|
|
||||||
let gNodes: d3.Selection<SVGGElement, unknown, null, undefined> | null = null
|
|
||||||
|
|
||||||
const isMobile = computed(() => width.value < 600)
|
|
||||||
const nodeRadius = computed(() => isMobile.value ? 22 : 28)
|
|
||||||
|
|
||||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function truncate(str: string, max = 10): string {
|
|
||||||
if (!str) return ''
|
|
||||||
return str.length > max ? str.slice(0, max - 1) + '…' : str
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildNodes(): SimNode[] {
|
|
||||||
return props.fiches.map(f => ({
|
|
||||||
id: f.id,
|
|
||||||
nom: f.nom,
|
|
||||||
offre: f.offre,
|
|
||||||
besoin: f.besoin,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildLinks(nodes: SimNode[]): SimLink[] {
|
|
||||||
if (!props.matches || props.matches.length === 0) return []
|
|
||||||
const nodeById = new Map(nodes.map(n => [n.id, n]))
|
|
||||||
return props.matches
|
|
||||||
.filter(m => nodeById.has(m.fromId) && nodeById.has(m.toId))
|
|
||||||
.map(m => ({
|
|
||||||
source: nodeById.get(m.fromId)!,
|
|
||||||
target: nodeById.get(m.toId)!,
|
|
||||||
score: m.score,
|
|
||||||
mode: m.mode,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
function linkColor(mode: string): string {
|
|
||||||
if (mode === 'solution') return '#22c55e'
|
|
||||||
if (mode === 'alliance') return '#f97316'
|
|
||||||
if (mode === 'surprise') return '#3b82f6'
|
|
||||||
return '#ccc'
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Drag handler ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function makeDrag(sim: d3.Simulation<SimNode, SimLink>): d3.DragBehavior<SVGGElement, SimNode, SimNode> {
|
|
||||||
return d3.drag<SVGGElement, SimNode>()
|
|
||||||
.on('start', (event, d) => {
|
|
||||||
if (!event.active) sim.alphaTarget(0.3).restart()
|
|
||||||
d.fx = d.x
|
|
||||||
d.fy = d.y
|
|
||||||
})
|
|
||||||
.on('drag', (event, d) => {
|
|
||||||
d.fx = event.x
|
|
||||||
d.fy = event.y
|
|
||||||
})
|
|
||||||
.on('end', (event, d) => {
|
|
||||||
if (!event.active) sim.alphaTarget(0)
|
|
||||||
d.fx = null
|
|
||||||
d.fy = null
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Initialisation SVG ─────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function initSvg() {
|
|
||||||
if (!svgEl.value) return
|
|
||||||
|
|
||||||
svgRoot = d3.select(svgEl.value)
|
|
||||||
.attr('width', width.value)
|
|
||||||
.attr('height', height.value)
|
|
||||||
|
|
||||||
svgRoot.selectAll('*').remove()
|
|
||||||
|
|
||||||
gLinks = svgRoot.append('g').attr('class', 'links')
|
|
||||||
gNodes = svgRoot.append('g').attr('class', 'nodes')
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Rebuild liens (hook pour M4) ───────────────────────────────────────────
|
|
||||||
|
|
||||||
let currentNodes: SimNode[] = []
|
|
||||||
let currentLinks: SimLink[] = []
|
|
||||||
|
|
||||||
function rebuildLinks() {
|
|
||||||
currentLinks = buildLinks(currentNodes)
|
|
||||||
if (!gLinks || !simulation) return
|
|
||||||
|
|
||||||
// .join() moderne D3 pour garantir le re-rendu complet
|
|
||||||
gLinks
|
|
||||||
.selectAll<SVGLineElement, SimLink>('line')
|
|
||||||
.data(currentLinks)
|
|
||||||
.join(
|
|
||||||
enter => enter.append('line'),
|
|
||||||
update => update,
|
|
||||||
exit => exit.remove()
|
|
||||||
)
|
|
||||||
.attr('stroke', d => linkColor(d.mode))
|
|
||||||
.attr('stroke-width', d => 1 + d.score * 3)
|
|
||||||
.attr('stroke-opacity', 0.7)
|
|
||||||
.attr('marker-end', d => d.mode === 'solution' ? 'url(#arrow-solution)' : null)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Rendu complet ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function render() {
|
|
||||||
if (!svgEl.value || props.fiches.length === 0) return
|
|
||||||
|
|
||||||
initSvg()
|
|
||||||
|
|
||||||
currentNodes = buildNodes()
|
|
||||||
currentLinks = buildLinks(currentNodes)
|
|
||||||
|
|
||||||
const r = nodeRadius.value
|
|
||||||
const fontSize = isMobile.value ? 10 : 12
|
|
||||||
|
|
||||||
// Liens
|
|
||||||
gLinks!
|
|
||||||
.selectAll<SVGLineElement, SimLink>('line')
|
|
||||||
.data(currentLinks)
|
|
||||||
.join('line')
|
|
||||||
.attr('stroke', d => linkColor(d.mode))
|
|
||||||
.attr('stroke-width', d => 1 + d.score * 3)
|
|
||||||
.attr('stroke-opacity', 0.7)
|
|
||||||
.attr('marker-end', d => d.mode === 'solution' ? 'url(#arrow-solution)' : null)
|
|
||||||
|
|
||||||
// Noeuds = groupe <g> par personne
|
|
||||||
const nodeGroups = gNodes!
|
|
||||||
.selectAll<SVGGElement, SimNode>('g.node')
|
|
||||||
.data(currentNodes, d => String(d.id))
|
|
||||||
.join('g')
|
|
||||||
.attr('class', 'node')
|
|
||||||
.style('cursor', 'pointer')
|
|
||||||
.call(makeDrag(simulation!) as any)
|
|
||||||
.on('click', (_event, d) => emit('select-fiche', d.id))
|
|
||||||
|
|
||||||
// Cercle principal
|
|
||||||
nodeGroups.append('circle')
|
|
||||||
.attr('r', r)
|
|
||||||
.attr('fill', '#ffffff')
|
|
||||||
.attr('stroke', '#1B4436')
|
|
||||||
.attr('stroke-width', 2)
|
|
||||||
|
|
||||||
// Label nom
|
|
||||||
nodeGroups.append('text')
|
|
||||||
.attr('text-anchor', 'middle')
|
|
||||||
.attr('dominant-baseline', 'central')
|
|
||||||
.attr('font-size', fontSize)
|
|
||||||
.attr('font-weight', '700')
|
|
||||||
.attr('fill', '#1a1a2e')
|
|
||||||
.attr('pointer-events', 'none')
|
|
||||||
.text(d => truncate(d.nom, 10))
|
|
||||||
|
|
||||||
// Pastille offre (haut-droite, vert)
|
|
||||||
nodeGroups.append('circle')
|
|
||||||
.attr('r', 6)
|
|
||||||
.attr('cx', r * 0.65)
|
|
||||||
.attr('cy', -r * 0.65)
|
|
||||||
.attr('fill', '#22c55e')
|
|
||||||
.attr('stroke', '#fff')
|
|
||||||
.attr('stroke-width', 1.5)
|
|
||||||
|
|
||||||
// Pastille besoin (bas-droite, bleu)
|
|
||||||
nodeGroups.append('circle')
|
|
||||||
.attr('r', 6)
|
|
||||||
.attr('cx', r * 0.65)
|
|
||||||
.attr('cy', r * 0.65)
|
|
||||||
.attr('fill', '#3b82f6')
|
|
||||||
.attr('stroke', '#fff')
|
|
||||||
.attr('stroke-width', 1.5)
|
|
||||||
|
|
||||||
// Tooltip SVG natif <title>
|
|
||||||
nodeGroups.append('title')
|
|
||||||
.text(d => `${d.nom}\nOffre : ${d.offre}\nBesoin : ${d.besoin}`)
|
|
||||||
|
|
||||||
// Groupe label bulle (affiche si showLabels)
|
|
||||||
const labelGroups = nodeGroups.append('g')
|
|
||||||
.attr('class', 'label-bubble')
|
|
||||||
.attr('visibility', props.showLabels ? 'visible' : 'hidden')
|
|
||||||
|
|
||||||
// Fond bulle besoin (dessous du noeud)
|
|
||||||
labelGroups.append('rect')
|
|
||||||
.attr('class', 'bubble-besoin-bg')
|
|
||||||
.attr('x', -(r + 50))
|
|
||||||
.attr('y', r + 4)
|
|
||||||
.attr('width', 100)
|
|
||||||
.attr('height', 28)
|
|
||||||
.attr('rx', 6)
|
|
||||||
.attr('fill', '#eff6ff')
|
|
||||||
.attr('stroke', '#3b82f6')
|
|
||||||
.attr('stroke-width', 1)
|
|
||||||
|
|
||||||
// Texte besoin
|
|
||||||
labelGroups.append('text')
|
|
||||||
.attr('class', 'bubble-besoin-txt')
|
|
||||||
.attr('x', -(r) + 50)
|
|
||||||
.attr('y', r + 22)
|
|
||||||
.attr('text-anchor', 'middle')
|
|
||||||
.attr('font-size', 9)
|
|
||||||
.attr('fill', '#1e40af')
|
|
||||||
.attr('pointer-events', 'none')
|
|
||||||
.text(d => truncate(d.besoin, 18))
|
|
||||||
|
|
||||||
// Fond bulle offre (dessus du noeud)
|
|
||||||
labelGroups.append('rect')
|
|
||||||
.attr('class', 'bubble-offre-bg')
|
|
||||||
.attr('x', -(r + 50))
|
|
||||||
.attr('y', -(r + 32))
|
|
||||||
.attr('width', 100)
|
|
||||||
.attr('height', 28)
|
|
||||||
.attr('rx', 6)
|
|
||||||
.attr('fill', '#f0fdf4')
|
|
||||||
.attr('stroke', '#22c55e')
|
|
||||||
.attr('stroke-width', 1)
|
|
||||||
|
|
||||||
// Texte offre
|
|
||||||
labelGroups.append('text')
|
|
||||||
.attr('class', 'bubble-offre-txt')
|
|
||||||
.attr('x', -(r) + 50)
|
|
||||||
.attr('y', -(r + 14))
|
|
||||||
.attr('text-anchor', 'middle')
|
|
||||||
.attr('font-size', 9)
|
|
||||||
.attr('fill', '#166534')
|
|
||||||
.attr('pointer-events', 'none')
|
|
||||||
.text(d => truncate(d.offre, 18))
|
|
||||||
|
|
||||||
// Simulation
|
|
||||||
simulation = d3.forceSimulation<SimNode, SimLink>(currentNodes)
|
|
||||||
.force('link', d3.forceLink<SimNode, SimLink>(currentLinks)
|
|
||||||
.id(d => d.id)
|
|
||||||
.distance(120)
|
|
||||||
.strength(0.3))
|
|
||||||
.force('charge', d3.forceManyBody<SimNode>().strength(-400))
|
|
||||||
.force('center', d3.forceCenter(width.value / 2, height.value / 2))
|
|
||||||
.force('collide', d3.forceCollide<SimNode>().radius(r + 12))
|
|
||||||
.force('x', d3.forceX(width.value / 2).strength(0.05))
|
|
||||||
.force('y', d3.forceY(height.value / 2).strength(0.05))
|
|
||||||
.alphaDecay(0.02)
|
|
||||||
.on('tick', tick)
|
|
||||||
|
|
||||||
// Re-bind drag avec la nouvelle simulation
|
|
||||||
gNodes!.selectAll<SVGGElement, SimNode>('g.node')
|
|
||||||
.call(makeDrag(simulation) as any)
|
|
||||||
}
|
|
||||||
|
|
||||||
function tick() {
|
|
||||||
const r = nodeRadius.value
|
|
||||||
if (!gLinks || !gNodes) return
|
|
||||||
|
|
||||||
gLinks.selectAll<SVGLineElement, SimLink>('line')
|
|
||||||
.attr('x1', d => Math.max(r, Math.min(width.value - r, (d.source as SimNode).x ?? 0)))
|
|
||||||
.attr('y1', d => Math.max(r, Math.min(height.value - r, (d.source as SimNode).y ?? 0)))
|
|
||||||
.attr('x2', d => Math.max(r, Math.min(width.value - r, (d.target as SimNode).x ?? 0)))
|
|
||||||
.attr('y2', d => Math.max(r, Math.min(height.value - r, (d.target as SimNode).y ?? 0)))
|
|
||||||
|
|
||||||
gNodes.selectAll<SVGGElement, SimNode>('g.node')
|
|
||||||
.attr('transform', d => {
|
|
||||||
const x = Math.max(r, Math.min(width.value - r, d.x ?? 0))
|
|
||||||
const y = Math.max(r, Math.min(height.value - r, d.y ?? 0))
|
|
||||||
return `translate(${x},${y})`
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Watch matches/mode (hook pour M4) ─────────────────────────────────────
|
|
||||||
|
|
||||||
watch(() => [props.matches, props.mode] as const, () => {
|
|
||||||
if (!simulation) return
|
|
||||||
rebuildLinks()
|
|
||||||
const newForce = d3.forceLink<SimNode, SimLink>(currentLinks)
|
|
||||||
.id(d => String(d.id))
|
|
||||||
.distance(120)
|
|
||||||
.strength(0.5)
|
|
||||||
simulation.force('link', newForce)
|
|
||||||
simulation.alpha(0.8).restart()
|
|
||||||
}, { deep: true })
|
|
||||||
|
|
||||||
// ── Watch showLabels ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
watch(() => props.showLabels, (val) => {
|
|
||||||
if (!svgEl.value) return
|
|
||||||
d3.select(svgEl.value).selectAll('.label-bubble').attr('visibility', val ? 'visible' : 'hidden')
|
|
||||||
})
|
|
||||||
|
|
||||||
// ── Watch fiches (re-render si nouvelles fiches) ───────────────────────────
|
|
||||||
|
|
||||||
watch(() => props.fiches, () => {
|
|
||||||
if (simulation) {
|
|
||||||
simulation.stop()
|
|
||||||
simulation = null
|
|
||||||
}
|
|
||||||
render()
|
|
||||||
}, { deep: true })
|
|
||||||
|
|
||||||
// ── ResizeObserver ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
let ro: ResizeObserver | null = null
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
if (!container.value) return
|
|
||||||
width.value = container.value.clientWidth || 800
|
|
||||||
height.value = container.value.clientHeight || 600
|
|
||||||
|
|
||||||
render()
|
|
||||||
|
|
||||||
ro = new ResizeObserver(() => {
|
|
||||||
if (!container.value) return
|
|
||||||
width.value = container.value.clientWidth || 800
|
|
||||||
height.value = container.value.clientHeight || 600
|
|
||||||
if (svgRoot) {
|
|
||||||
svgRoot.attr('width', width.value).attr('height', height.value)
|
|
||||||
}
|
|
||||||
if (simulation) {
|
|
||||||
simulation.force('center', d3.forceCenter(width.value / 2, height.value / 2))
|
|
||||||
simulation.alpha(0.3).restart()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
ro.observe(container.value!)
|
|
||||||
})
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
if (simulation) simulation.stop()
|
|
||||||
if (ro) ro.disconnect()
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.codev-graph-wrap {
|
|
||||||
width: 100%;
|
|
||||||
height: 70vh;
|
|
||||||
min-height: 320px;
|
|
||||||
position: relative;
|
|
||||||
background: var(--nav-bg, #fafafa);
|
|
||||||
border-radius: 12px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.codev-svg {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Etat vide ── */
|
|
||||||
|
|
||||||
.empty-state {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 1rem;
|
|
||||||
padding: 2rem;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-msg {
|
|
||||||
font-size: 1.125rem;
|
|
||||||
color: var(--nav-text-muted, #6b7280);
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-link {
|
|
||||||
font-size: 0.9rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--nav-primary-solid, #1B4436);
|
|
||||||
text-decoration: none;
|
|
||||||
border: 1.5px solid var(--nav-primary-solid, #1B4436);
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 0.5rem 1.25rem;
|
|
||||||
transition: background 0.15s, color 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-link:hover {
|
|
||||||
background: var(--nav-primary-solid, #1B4436);
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Mobile ── */
|
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
|
||||||
.codev-graph-wrap {
|
|
||||||
height: 65vh;
|
|
||||||
min-height: 260px;
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,240 +0,0 @@
|
|||||||
.proposer-form {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 1.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.field-group {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.375rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.field-group label,
|
|
||||||
.field-group legend {
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--nav-text);
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.field-group fieldset {
|
|
||||||
border: none;
|
|
||||||
padding: 0;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.required { color: #c0392b; }
|
|
||||||
|
|
||||||
.label-hint {
|
|
||||||
font-weight: 400;
|
|
||||||
color: var(--nav-text-muted);
|
|
||||||
font-size: 0.8rem;
|
|
||||||
margin-left: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.field-group input[type="text"],
|
|
||||||
.field-group input[type="url"],
|
|
||||||
.field-group input[type="email"],
|
|
||||||
.field-group textarea {
|
|
||||||
width: 100%;
|
|
||||||
padding: 0.625rem 0.875rem;
|
|
||||||
border: 1px solid rgba(26, 34, 56, 0.2);
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
color: var(--nav-text);
|
|
||||||
background: var(--nav-surface);
|
|
||||||
font-family: inherit;
|
|
||||||
transition: border-color 0.15s, box-shadow 0.15s;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
.field-group input:focus,
|
|
||||||
.field-group textarea:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: var(--nav-primary-solid);
|
|
||||||
box-shadow: 0 0 0 2px rgba(245, 179, 66, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.field-group textarea {
|
|
||||||
resize: vertical;
|
|
||||||
min-height: 100px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.field-error input,
|
|
||||||
.field-error textarea {
|
|
||||||
border-color: #c0392b !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-msg {
|
|
||||||
font-size: 0.8rem;
|
|
||||||
color: #c0392b;
|
|
||||||
}
|
|
||||||
|
|
||||||
.field-meta {
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
}
|
|
||||||
|
|
||||||
.char-count {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: var(--nav-text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.char-warn {
|
|
||||||
color: #e67e22;
|
|
||||||
}
|
|
||||||
|
|
||||||
.radio-group {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.5rem;
|
|
||||||
margin-top: 0.375rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.radio-label {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.375rem;
|
|
||||||
padding: 0.375rem 0.75rem;
|
|
||||||
border: 1px solid rgba(26, 34, 56, 0.2);
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
color: var(--nav-text);
|
|
||||||
background: var(--nav-surface);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.15s;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.radio-label input[type="radio"] {
|
|
||||||
position: absolute;
|
|
||||||
opacity: 0;
|
|
||||||
width: 0;
|
|
||||||
height: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.radio-label:hover {
|
|
||||||
border-color: var(--nav-primary-solid);
|
|
||||||
background: var(--nav-bg-alt);
|
|
||||||
}
|
|
||||||
|
|
||||||
.radio-label.active {
|
|
||||||
background: var(--nav-primary);
|
|
||||||
border-color: transparent;
|
|
||||||
color: var(--nav-text-on-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.checkbox-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
gap: 0.5rem;
|
|
||||||
margin-top: 0.375rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 400px) {
|
|
||||||
.checkbox-grid { grid-template-columns: 1fr; }
|
|
||||||
}
|
|
||||||
|
|
||||||
.checkbox-label {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.375rem;
|
|
||||||
padding: 0.375rem 0.75rem;
|
|
||||||
border: 1px solid rgba(26, 34, 56, 0.2);
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
color: var(--nav-text);
|
|
||||||
background: var(--nav-surface);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.15s;
|
|
||||||
user-select: none;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.checkbox-label input[type="checkbox"] {
|
|
||||||
position: absolute;
|
|
||||||
opacity: 0;
|
|
||||||
width: 0;
|
|
||||||
height: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.checkbox-label:hover:not(.disabled) {
|
|
||||||
border-color: var(--nav-primary-solid);
|
|
||||||
background: var(--nav-bg-alt);
|
|
||||||
}
|
|
||||||
|
|
||||||
.checkbox-label.active {
|
|
||||||
background: var(--nav-primary);
|
|
||||||
border-color: transparent;
|
|
||||||
color: var(--nav-text-on-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.checkbox-label.disabled {
|
|
||||||
opacity: 0.4;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fn-order {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 18px;
|
|
||||||
height: 18px;
|
|
||||||
background: var(--nav-accent);
|
|
||||||
color: var(--nav-text);
|
|
||||||
border-radius: 50%;
|
|
||||||
font-size: 0.7rem;
|
|
||||||
font-weight: 700;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.server-error {
|
|
||||||
padding: 0.875rem 1rem;
|
|
||||||
background: #fdf0ee;
|
|
||||||
border: 1px solid #e74c3c;
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
color: #c0392b;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.75rem;
|
|
||||||
justify-content: flex-end;
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary {
|
|
||||||
padding: 0.75rem 1.5rem;
|
|
||||||
background: var(--nav-primary);
|
|
||||||
color: var(--nav-text-on-primary);
|
|
||||||
border: none;
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
font-family: inherit;
|
|
||||||
transition: background 0.15s, opacity 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary:hover:not(:disabled) {
|
|
||||||
background: rgba(26, 34, 56, 0.75);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-note {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: var(--nav-text-muted);
|
|
||||||
text-align: center;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
|
||||||
.form-actions { flex-direction: column-reverse; }
|
|
||||||
.btn-primary { width: 100%; justify-content: center; }
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
// Fond de carte commun aux cartes Leaflet.
|
|
||||||
// CARTO exige une clé depuis le 23/09/2026 (sans clé : filigrane « API KEY REQUIRED »).
|
|
||||||
// Clé fournie au runtime par NUXT_PUBLIC_CARTO_KEY ; sans clé, repli sur le Plan IGN (Géoplateforme, sans clé).
|
|
||||||
const IGN_URL = 'https://data.geopf.fr/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0'
|
|
||||||
+ '&LAYER=GEOGRAPHICALGRIDSYSTEMS.PLANIGNV2&STYLE=normal&TILEMATRIXSET=PM_0_19&FORMAT=image/png'
|
|
||||||
+ '&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}'
|
|
||||||
|
|
||||||
export function useBaseTiles() {
|
|
||||||
const key = useRuntimeConfig().public.cartoKey as string
|
|
||||||
|
|
||||||
function url(dark: boolean) {
|
|
||||||
if (!key) return IGN_URL
|
|
||||||
const style = dark ? 'dark_all' : 'light_all'
|
|
||||||
return `https://{s}.basemaps.cartocdn.com/${style}/{z}/{x}/{y}{r}.png?key=${encodeURIComponent(key)}`
|
|
||||||
}
|
|
||||||
|
|
||||||
const attribution = key
|
|
||||||
? '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors © <a href="https://carto.com/attributions">CARTO</a>'
|
|
||||||
: '© <a href="https://www.ign.fr/">IGN</a> – Géoplateforme'
|
|
||||||
|
|
||||||
return { url, attribution }
|
|
||||||
}
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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 }
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
/**
|
|
||||||
* Convertit du Markdown Mistral en HTML avec inline styles.
|
|
||||||
* Inline styles = zéro dépendance CSS, fonctionne dans tout contexte Vue (scoped, v-html, etc.)
|
|
||||||
*/
|
|
||||||
export function useMarkdown() {
|
|
||||||
const S = {
|
|
||||||
p: 'style="margin:0 0 0.45em;line-height:1.6;"',
|
|
||||||
strong: 'style="font-weight:700;"',
|
|
||||||
em: 'style="font-style:italic;"',
|
|
||||||
h2: 'style="font-weight:700;display:block;margin-bottom:0.2em;"',
|
|
||||||
h3: 'style="font-weight:700;display:block;font-size:0.95em;margin-bottom:0.15em;"',
|
|
||||||
ul: 'style="margin:0.3em 0 0.3em 1.2em;padding:0;list-style:disc;"',
|
|
||||||
li: 'style="margin-bottom:0.15em;"',
|
|
||||||
a: 'style="text-decoration:underline;opacity:0.85;"',
|
|
||||||
}
|
|
||||||
|
|
||||||
function render(text: string): string {
|
|
||||||
if (!text) return ''
|
|
||||||
let html = text
|
|
||||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
||||||
.replace(/^### (.+)$/gm, `<strong ${S.h3}>$1</strong>`)
|
|
||||||
.replace(/^## (.+)$/gm, `<strong ${S.h2}>$1</strong>`)
|
|
||||||
.replace(/^# (.+)$/gm, `<strong ${S.h2}>$1</strong>`)
|
|
||||||
.replace(/\*\*(.+?)\*\*/g, `<strong ${S.strong}>$1</strong>`)
|
|
||||||
.replace(/\*(.+?)\*/g, `<em ${S.em}>$1</em>`)
|
|
||||||
.replace(/^[-•]\s+(.+)$/gm, `<li ${S.li}>$1</li>`)
|
|
||||||
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, `<a href="$2" target="_blank" rel="noopener" ${S.a}>$1</a>`)
|
|
||||||
|
|
||||||
html = html.replace(/(<li[^>]*>.*<\/li>\n?)+/g, m => `<ul ${S.ul}>${m}</ul>`)
|
|
||||||
html = html.replace(/\n{2,}/g, `</p><p ${S.p}>`)
|
|
||||||
html = html.replace(/\n/g, '<br>')
|
|
||||||
return `<p ${S.p}>${html}</p>`
|
|
||||||
}
|
|
||||||
|
|
||||||
return { render }
|
|
||||||
}
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
# Déploiement aep-worker (B5-M2)
|
|
||||||
|
|
||||||
Ce dossier n'a **rien été exécuté ni copié sur le VPS** par la session B5 — code
|
|
||||||
seulement, comme demandé. Étapes exactes pour la session qui déploiera.
|
|
||||||
|
|
||||||
## 0. Pré-requis avant de commencer
|
|
||||||
|
|
||||||
- [ ] **Backup NocoDB < 24 h** avant le premier passage du worker (il écrit en base).
|
|
||||||
- [ ] Vérifier que `/opt/aep/.env` contient déjà : `NOCODB_URL`, `NOCODB_TOKEN`,
|
|
||||||
`NOCODB_BASE`, `NOCODB_TABLE_ORGAS`, `NOCODB_TABLE_STATS`, `BIFROST_URL`,
|
|
||||||
`BIFROST_VK`, `NTFY_TOPIC` (et optionnellement `WORKER_MODEL`,
|
|
||||||
`WORKER_LIMIT`, `BUDGET_MAX_EUR`). Ne PAS créer de nouveau fichier .env —
|
|
||||||
celui-ci existe déjà en prod (mission dit de le lire, pas de le chercher
|
|
||||||
dans ce repo).
|
|
||||||
- [ ] Confirmer que `/opt/nav-carte` (l'ancien worker) est bien absent — il l'était
|
|
||||||
au 27/09 selon B0. Sinon, l'arrêter/purger avant d'activer celui-ci pour
|
|
||||||
éviter un double traitement des mêmes fiches pending.
|
|
||||||
|
|
||||||
## 1. Copier le code
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ssh vps
|
|
||||||
mkdir -p /opt/aep-worker
|
|
||||||
exit
|
|
||||||
# depuis le poste de dev, après avoir buildé/vérifié la branche :
|
|
||||||
scp -r worker/enrich.js worker/package.json worker/fixtures vps:/opt/aep-worker/
|
|
||||||
ssh vps "cd /opt/aep-worker && npm install --omit=dev"
|
|
||||||
```
|
|
||||||
|
|
||||||
(`npm install` : le worker n'a plus de dépendance à `dotenv` côté prod puisque
|
|
||||||
`EnvironmentFile=` du service systemd charge déjà `/opt/aep/.env` — le
|
|
||||||
`package.json` du worker peut être simplifié à cette occasion, ou laissé tel
|
|
||||||
quel si `dotenv` sert encore pour un lancement manuel en local.)
|
|
||||||
|
|
||||||
## 2. Poser les unités systemd
|
|
||||||
|
|
||||||
```bash
|
|
||||||
scp deploy/aep-worker/aep-worker.service deploy/aep-worker/aep-worker.timer \
|
|
||||||
vps:/tmp/
|
|
||||||
ssh vps
|
|
||||||
sudo mv /tmp/aep-worker.service /tmp/aep-worker.timer /etc/systemd/system/
|
|
||||||
sudo systemctl daemon-reload
|
|
||||||
sudo systemctl enable --now aep-worker.timer
|
|
||||||
```
|
|
||||||
|
|
||||||
## 3. Vérifier
|
|
||||||
|
|
||||||
```bash
|
|
||||||
systemctl status aep-worker.timer
|
|
||||||
systemctl list-timers | grep aep-worker
|
|
||||||
|
|
||||||
# Lancer un passage manuel (sans attendre les 15 min) :
|
|
||||||
sudo systemctl start aep-worker.service
|
|
||||||
journalctl -u aep-worker.service -n 100 --no-pager
|
|
||||||
```
|
|
||||||
|
|
||||||
Logs attendus : `=== Worker AEP enrichissement démarré ===`, un compte de
|
|
||||||
fiches pending, puis soit `Rien à traiter.` soit le détail par fiche
|
|
||||||
(scrape/Bifrost/ntfy).
|
|
||||||
|
|
||||||
## 4. Test bout-en-bout (M3, session suivante)
|
|
||||||
|
|
||||||
1. Soumettre une vraie ressource de test via `/proposer` (formulaire assoupli).
|
|
||||||
2. Vérifier le 201 + le record `pending`/`ai_processed=false` dans NocoDB.
|
|
||||||
3. Vérifier la notif ntfy de soumission (topic `NTFY_TOPIC`).
|
|
||||||
4. Lancer le worker (`systemctl start aep-worker.service` ou attendre le
|
|
||||||
timer) → vérifier `moderation_status=ai_processed`, `description_enrichie`,
|
|
||||||
`tags_fonction`, `ai_raw_output` remplis, et la notif ntfy « Fiche enrichie ».
|
|
||||||
5. Modérer dans NocoDB UI (`ai_processed` → `approved`) → vérifier l'affichage
|
|
||||||
sur le front.
|
|
||||||
6. **Supprimer les records de test** dans NocoDB (orgas + stats_usage) une
|
|
||||||
fois la preuve faite.
|
|
||||||
7. Mettre à jour `PIPE-IA-DOC.md`, `Onglets/Proposer.md`, `PILOTE - AEP.md`
|
|
||||||
dans le vault avec le résultat réel (pas seulement ce README).
|
|
||||||
|
|
||||||
## Anti-chevauchement
|
|
||||||
|
|
||||||
Lock fichier PID : `/tmp/aep-worker.lock` (configurable via `WORKER_LOCK_FILE`
|
|
||||||
dans `.env` si besoin). Nettoyé en fin de run normal, et automatiquement si le
|
|
||||||
PID contenu dans le lock n'est plus vivant (voir `acquireLock()` dans
|
|
||||||
`enrich.js`) — pas d'intervention manuelle attendue, sauf lock resté après un
|
|
||||||
crash dur du process (`kill -9` externe) : dans ce cas `rm /tmp/aep-worker.lock`
|
|
||||||
suffit.
|
|
||||||
|
|
||||||
## Écarts par rapport à la version NAV V2 (PIPE-IA-DOC.md)
|
|
||||||
|
|
||||||
Voir l'en-tête de commentaire de `worker/enrich.js` et `PIPE-IA-DOC.md` §11 —
|
|
||||||
LLM Bifrost au lieu de Mistral direct, scrape fetch natif au lieu de crawl4ai,
|
|
||||||
notification ntfy au lieu de Resend, écriture `nom`/`submission_type` par le
|
|
||||||
worker seulement quand le formulaire assoupli a laissé un placeholder.
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
[Unit]
|
|
||||||
Description=AEP — Worker enrichissement IA (scrape léger + Bifrost)
|
|
||||||
After=network-online.target
|
|
||||||
Wants=network-online.target
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=oneshot
|
|
||||||
WorkingDirectory=/opt/aep-worker
|
|
||||||
EnvironmentFile=/opt/aep/.env
|
|
||||||
ExecStart=/usr/bin/node enrich.js
|
|
||||||
TimeoutStartSec=300
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
[Unit]
|
|
||||||
Description=Timer 15 min — worker enrichissement IA AEP
|
|
||||||
|
|
||||||
[Timer]
|
|
||||||
OnCalendar=*:0/15
|
|
||||||
Persistent=true
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=timers.target
|
|
||||||
+3
-32
@@ -1,25 +1,6 @@
|
|||||||
export default defineNuxtConfig({
|
export default defineNuxtConfig({
|
||||||
modules: ['@nuxtjs/tailwindcss'],
|
modules: ['@nuxtjs/tailwindcss'],
|
||||||
|
css: ['~/assets/css/main.css'],
|
||||||
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',
|
|
||||||
'~/assets/css/main.css',
|
|
||||||
'leaflet/dist/leaflet.css',
|
|
||||||
'leaflet.markercluster/dist/MarkerCluster.css',
|
|
||||||
'leaflet.markercluster/dist/MarkerCluster.Default.css',
|
|
||||||
],
|
|
||||||
|
|
||||||
runtimeConfig: {
|
runtimeConfig: {
|
||||||
nocodbUrl: process.env.NOCODB_URL,
|
nocodbUrl: process.env.NOCODB_URL,
|
||||||
@@ -30,20 +11,9 @@ export default defineNuxtConfig({
|
|||||||
commentTableId: process.env.COMMENT_TABLE_ID || process.env.AVIS_TABLE_ID,
|
commentTableId: process.env.COMMENT_TABLE_ID || process.env.AVIS_TABLE_ID,
|
||||||
statsTableId: process.env.STATS_TABLE_ID || 'mbbq7n47ixy19mc',
|
statsTableId: process.env.STATS_TABLE_ID || 'mbbq7n47ixy19mc',
|
||||||
mistralApiKey: process.env.MISTRAL_API_KEY,
|
mistralApiKey: process.env.MISTRAL_API_KEY,
|
||||||
bifrostUrl: process.env.BIFROST_URL || 'http://127.0.0.1:8080',
|
|
||||||
bifrostVk: process.env.BIFROST_VK,
|
|
||||||
redisUrl: process.env.REDIS_URL || 'redis://127.0.0.1:6379',
|
redisUrl: process.env.REDIS_URL || 'redis://127.0.0.1:6379',
|
||||||
resendApiKey: process.env.RESEND_API_KEY,
|
resendApiKey: process.env.RESEND_API_KEY,
|
||||||
emailJules: process.env.EMAIL_JULES || 'jules@trans-former.fr',
|
emailJules: process.env.EMAIL_JULES || 'jules@trans-former.fr',
|
||||||
codevTableId: '', // NUXT_CODEV_TABLE_ID
|
|
||||||
codevPassword: 'merci', // NUXT_CODEV_PASSWORD - défaut "merci", overridable
|
|
||||||
codevBaseId: '', // NUXT_CODEV_BASE_ID - base NocoDB (ex: pipilvsi7dibo80)
|
|
||||||
codevAdminPassword: 'admin2026', // NUXT_CODEV_ADMIN_PASSWORD
|
|
||||||
ragPeUrl: process.env.RAG_PE_URL || 'http://localhost:9621',
|
|
||||||
referencesTableId: process.env.REFERENCES_TABLE_ID || process.env.NUXT_REFERENCES_TABLE_ID || '',
|
|
||||||
public: {
|
|
||||||
cartoKey: '', // NUXT_PUBLIC_CARTO_KEY — clé CARTO Basemaps (gratuite), lue au runtime
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// Leaflet ne fonctionne pas en SSR — forcer le rendu côté client
|
// Leaflet ne fonctionne pas en SSR — forcer le rendu côté client
|
||||||
@@ -51,8 +21,9 @@ export default defineNuxtConfig({
|
|||||||
|
|
||||||
vite: {
|
vite: {
|
||||||
optimizeDeps: {
|
optimizeDeps: {
|
||||||
include: ['leaflet', 'leaflet.markercluster', 'd3'],
|
include: ['leaflet', 'leaflet.markercluster'],
|
||||||
},
|
},
|
||||||
|
// Éviter l'import SSR de Leaflet qui utilise window
|
||||||
ssr: {
|
ssr: {
|
||||||
noExternal: [],
|
noExternal: [],
|
||||||
},
|
},
|
||||||
|
|||||||
Generated
-465
@@ -8,10 +8,8 @@
|
|||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@headlessui/vue": "^1.7.23",
|
"@headlessui/vue": "^1.7.23",
|
||||||
"@transformer/ui": "git+https://git.trans-former.fr/jules/design-system.git",
|
|
||||||
"@types/leaflet": "^1.9.21",
|
"@types/leaflet": "^1.9.21",
|
||||||
"@types/leaflet.markercluster": "^1.5.6",
|
"@types/leaflet.markercluster": "^1.5.6",
|
||||||
"d3": "^7.9.0",
|
|
||||||
"ioredis": "^5.3.2",
|
"ioredis": "^5.3.2",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
"leaflet.markercluster": "^1.5.3",
|
"leaflet.markercluster": "^1.5.3",
|
||||||
@@ -3626,11 +3624,6 @@
|
|||||||
"vue": "^2.7.0 || ^3.0.0"
|
"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": {
|
"node_modules/@tybys/wasm-util": {
|
||||||
"version": "0.10.1",
|
"version": "0.10.1",
|
||||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
|
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
|
||||||
@@ -5319,416 +5312,6 @@
|
|||||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/d3": {
|
|
||||||
"version": "7.9.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz",
|
|
||||||
"integrity": "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==",
|
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
|
||||||
"d3-array": "3",
|
|
||||||
"d3-axis": "3",
|
|
||||||
"d3-brush": "3",
|
|
||||||
"d3-chord": "3",
|
|
||||||
"d3-color": "3",
|
|
||||||
"d3-contour": "4",
|
|
||||||
"d3-delaunay": "6",
|
|
||||||
"d3-dispatch": "3",
|
|
||||||
"d3-drag": "3",
|
|
||||||
"d3-dsv": "3",
|
|
||||||
"d3-ease": "3",
|
|
||||||
"d3-fetch": "3",
|
|
||||||
"d3-force": "3",
|
|
||||||
"d3-format": "3",
|
|
||||||
"d3-geo": "3",
|
|
||||||
"d3-hierarchy": "3",
|
|
||||||
"d3-interpolate": "3",
|
|
||||||
"d3-path": "3",
|
|
||||||
"d3-polygon": "3",
|
|
||||||
"d3-quadtree": "3",
|
|
||||||
"d3-random": "3",
|
|
||||||
"d3-scale": "4",
|
|
||||||
"d3-scale-chromatic": "3",
|
|
||||||
"d3-selection": "3",
|
|
||||||
"d3-shape": "3",
|
|
||||||
"d3-time": "3",
|
|
||||||
"d3-time-format": "4",
|
|
||||||
"d3-timer": "3",
|
|
||||||
"d3-transition": "3",
|
|
||||||
"d3-zoom": "3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/d3-array": {
|
|
||||||
"version": "3.2.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
|
||||||
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
|
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
|
||||||
"internmap": "1 - 2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/d3-axis": {
|
|
||||||
"version": "3.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz",
|
|
||||||
"integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==",
|
|
||||||
"license": "ISC",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/d3-brush": {
|
|
||||||
"version": "3.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz",
|
|
||||||
"integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==",
|
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
|
||||||
"d3-dispatch": "1 - 3",
|
|
||||||
"d3-drag": "2 - 3",
|
|
||||||
"d3-interpolate": "1 - 3",
|
|
||||||
"d3-selection": "3",
|
|
||||||
"d3-transition": "3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/d3-chord": {
|
|
||||||
"version": "3.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-3.0.1.tgz",
|
|
||||||
"integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==",
|
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
|
||||||
"d3-path": "1 - 3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/d3-color": {
|
|
||||||
"version": "3.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
|
||||||
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
|
|
||||||
"license": "ISC",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/d3-contour": {
|
|
||||||
"version": "4.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-4.0.2.tgz",
|
|
||||||
"integrity": "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==",
|
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
|
||||||
"d3-array": "^3.2.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/d3-delaunay": {
|
|
||||||
"version": "6.0.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz",
|
|
||||||
"integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==",
|
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
|
||||||
"delaunator": "5"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/d3-dispatch": {
|
|
||||||
"version": "3.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
|
|
||||||
"integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
|
|
||||||
"license": "ISC",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/d3-drag": {
|
|
||||||
"version": "3.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz",
|
|
||||||
"integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
|
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
|
||||||
"d3-dispatch": "1 - 3",
|
|
||||||
"d3-selection": "3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/d3-dsv": {
|
|
||||||
"version": "3.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz",
|
|
||||||
"integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==",
|
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
|
||||||
"commander": "7",
|
|
||||||
"iconv-lite": "0.6",
|
|
||||||
"rw": "1"
|
|
||||||
},
|
|
||||||
"bin": {
|
|
||||||
"csv2json": "bin/dsv2json.js",
|
|
||||||
"csv2tsv": "bin/dsv2dsv.js",
|
|
||||||
"dsv2dsv": "bin/dsv2dsv.js",
|
|
||||||
"dsv2json": "bin/dsv2json.js",
|
|
||||||
"json2csv": "bin/json2dsv.js",
|
|
||||||
"json2dsv": "bin/json2dsv.js",
|
|
||||||
"json2tsv": "bin/json2dsv.js",
|
|
||||||
"tsv2csv": "bin/dsv2dsv.js",
|
|
||||||
"tsv2json": "bin/dsv2json.js"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/d3-dsv/node_modules/commander": {
|
|
||||||
"version": "7.2.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz",
|
|
||||||
"integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/d3-ease": {
|
|
||||||
"version": "3.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
|
|
||||||
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
|
|
||||||
"license": "BSD-3-Clause",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/d3-fetch": {
|
|
||||||
"version": "3.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3-fetch/-/d3-fetch-3.0.1.tgz",
|
|
||||||
"integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==",
|
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
|
||||||
"d3-dsv": "1 - 3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/d3-force": {
|
|
||||||
"version": "3.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz",
|
|
||||||
"integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==",
|
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
|
||||||
"d3-dispatch": "1 - 3",
|
|
||||||
"d3-quadtree": "1 - 3",
|
|
||||||
"d3-timer": "1 - 3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/d3-format": {
|
|
||||||
"version": "3.1.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
|
|
||||||
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
|
|
||||||
"license": "ISC",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/d3-geo": {
|
|
||||||
"version": "3.1.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz",
|
|
||||||
"integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==",
|
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
|
||||||
"d3-array": "2.5.0 - 3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/d3-hierarchy": {
|
|
||||||
"version": "3.1.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz",
|
|
||||||
"integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==",
|
|
||||||
"license": "ISC",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/d3-interpolate": {
|
|
||||||
"version": "3.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
|
||||||
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
|
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
|
||||||
"d3-color": "1 - 3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/d3-path": {
|
|
||||||
"version": "3.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
|
|
||||||
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
|
|
||||||
"license": "ISC",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/d3-polygon": {
|
|
||||||
"version": "3.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-3.0.1.tgz",
|
|
||||||
"integrity": "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==",
|
|
||||||
"license": "ISC",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/d3-quadtree": {
|
|
||||||
"version": "3.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz",
|
|
||||||
"integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==",
|
|
||||||
"license": "ISC",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/d3-random": {
|
|
||||||
"version": "3.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3-random/-/d3-random-3.0.1.tgz",
|
|
||||||
"integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==",
|
|
||||||
"license": "ISC",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/d3-scale": {
|
|
||||||
"version": "4.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
|
|
||||||
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
|
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
|
||||||
"d3-array": "2.10.0 - 3",
|
|
||||||
"d3-format": "1 - 3",
|
|
||||||
"d3-interpolate": "1.2.0 - 3",
|
|
||||||
"d3-time": "2.1.1 - 3",
|
|
||||||
"d3-time-format": "2 - 4"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/d3-scale-chromatic": {
|
|
||||||
"version": "3.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz",
|
|
||||||
"integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==",
|
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
|
||||||
"d3-color": "1 - 3",
|
|
||||||
"d3-interpolate": "1 - 3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/d3-selection": {
|
|
||||||
"version": "3.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
|
|
||||||
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
|
|
||||||
"license": "ISC",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/d3-shape": {
|
|
||||||
"version": "3.2.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
|
|
||||||
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
|
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
|
||||||
"d3-path": "^3.1.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/d3-time": {
|
|
||||||
"version": "3.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
|
|
||||||
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
|
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
|
||||||
"d3-array": "2 - 3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/d3-time-format": {
|
|
||||||
"version": "4.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
|
|
||||||
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
|
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
|
||||||
"d3-time": "1 - 3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/d3-timer": {
|
|
||||||
"version": "3.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
|
|
||||||
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
|
|
||||||
"license": "ISC",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/d3-transition": {
|
|
||||||
"version": "3.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz",
|
|
||||||
"integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==",
|
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
|
||||||
"d3-color": "1 - 3",
|
|
||||||
"d3-dispatch": "1 - 3",
|
|
||||||
"d3-ease": "1 - 3",
|
|
||||||
"d3-interpolate": "1 - 3",
|
|
||||||
"d3-timer": "1 - 3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"d3-selection": "2 - 3"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/d3-zoom": {
|
|
||||||
"version": "3.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz",
|
|
||||||
"integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==",
|
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
|
||||||
"d3-dispatch": "1 - 3",
|
|
||||||
"d3-drag": "2 - 3",
|
|
||||||
"d3-interpolate": "1 - 3",
|
|
||||||
"d3-selection": "2 - 3",
|
|
||||||
"d3-transition": "2 - 3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/db0": {
|
"node_modules/db0": {
|
||||||
"version": "0.3.4",
|
"version": "0.3.4",
|
||||||
"resolved": "https://registry.npmjs.org/db0/-/db0-0.3.4.tgz",
|
"resolved": "https://registry.npmjs.org/db0/-/db0-0.3.4.tgz",
|
||||||
@@ -5842,15 +5425,6 @@
|
|||||||
"integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==",
|
"integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/delaunator": {
|
|
||||||
"version": "5.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.1.0.tgz",
|
|
||||||
"integrity": "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==",
|
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
|
||||||
"robust-predicates": "^3.0.2"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/delegates": {
|
"node_modules/delegates": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz",
|
||||||
@@ -6906,18 +6480,6 @@
|
|||||||
"node": ">=16.17.0"
|
"node": ">=16.17.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/iconv-lite": {
|
|
||||||
"version": "0.6.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
|
||||||
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.10.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/ieee754": {
|
"node_modules/ieee754": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
||||||
@@ -6993,15 +6555,6 @@
|
|||||||
"node": "^14.17.0 || ^16.13.0 || >=18.0.0"
|
"node": "^14.17.0 || ^16.13.0 || >=18.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/internmap": {
|
|
||||||
"version": "2.0.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
|
||||||
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
|
|
||||||
"license": "ISC",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/ioredis": {
|
"node_modules/ioredis": {
|
||||||
"version": "5.10.1",
|
"version": "5.10.1",
|
||||||
"resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.10.1.tgz",
|
"resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.10.1.tgz",
|
||||||
@@ -9927,12 +9480,6 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/robust-predicates": {
|
|
||||||
"version": "3.0.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz",
|
|
||||||
"integrity": "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==",
|
|
||||||
"license": "Unlicense"
|
|
||||||
},
|
|
||||||
"node_modules/rollup": {
|
"node_modules/rollup": {
|
||||||
"version": "4.60.1",
|
"version": "4.60.1",
|
||||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz",
|
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz",
|
||||||
@@ -10048,12 +9595,6 @@
|
|||||||
"queue-microtask": "^1.2.2"
|
"queue-microtask": "^1.2.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/rw": {
|
|
||||||
"version": "1.3.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz",
|
|
||||||
"integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==",
|
|
||||||
"license": "BSD-3-Clause"
|
|
||||||
},
|
|
||||||
"node_modules/safe-buffer": {
|
"node_modules/safe-buffer": {
|
||||||
"version": "5.2.1",
|
"version": "5.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||||
@@ -10092,12 +9633,6 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/safer-buffer": {
|
|
||||||
"version": "2.1.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
|
||||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/sax": {
|
"node_modules/sax": {
|
||||||
"version": "1.6.0",
|
"version": "1.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz",
|
||||||
|
|||||||
@@ -11,10 +11,8 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@headlessui/vue": "^1.7.23",
|
"@headlessui/vue": "^1.7.23",
|
||||||
"@transformer/ui": "git+https://git.trans-former.fr/jules/design-system.git",
|
|
||||||
"@types/leaflet": "^1.9.21",
|
"@types/leaflet": "^1.9.21",
|
||||||
"@types/leaflet.markercluster": "^1.5.6",
|
"@types/leaflet.markercluster": "^1.5.6",
|
||||||
"d3": "^7.9.0",
|
|
||||||
"ioredis": "^5.3.2",
|
"ioredis": "^5.3.2",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
"leaflet.markercluster": "^1.5.3",
|
"leaflet.markercluster": "^1.5.3",
|
||||||
|
|||||||
+74
-75
@@ -8,12 +8,16 @@
|
|||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
|
|
||||||
<!-- ══════════════════════════════════════════════════════════
|
<!-- ══════════════════════════════════════════════════════════
|
||||||
SECTION INTRO - À propos d'AEP
|
SECTION 1 - Mission AEP
|
||||||
══════════════════════════════════════════════════════════ -->
|
══════════════════════════════════════════════════════════ -->
|
||||||
|
<!-- TODO Jules : Écrire le pitch (~100 mots) - qui est AEP, pour qui, pourquoi, quelle promesse -->
|
||||||
<section class="section-mission">
|
<section class="section-mission">
|
||||||
<h1>À propos d'AEP</h1>
|
<h1>Architecture d'Écologie Politique</h1>
|
||||||
<p class="mission-text">
|
<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 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.
|
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é.
|
||||||
|
</p>
|
||||||
|
<p class="mission-text">
|
||||||
|
Cette carte est née de cette frustration - et de cette conviction : les ressources existent, les gens qui ont réussi à sortir la tête de l'eau aussi. L'enjeu, c'est de les documenter, de les rendre accessibles, de les ajuster en temps réel grâce aux retours de la communauté. Pas un catalogue figé ; un commun vivant, au service de ceux et celles qui cherchent à faire évoluer leur pratique vers quelque chose de plus épanouissant, mieux rémunéré, au service de la société - et qui prend soin de la santé, la nôtre et celle des gens pour qui nous construisons.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -27,6 +31,14 @@
|
|||||||
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.
|
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>
|
</p>
|
||||||
<div class="badges-row">
|
<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">
|
<div class="badge">
|
||||||
<span class="badge-icon" aria-hidden="true">
|
<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>
|
<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>
|
||||||
@@ -95,8 +107,22 @@
|
|||||||
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.
|
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>
|
</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">
|
<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>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>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -168,38 +194,12 @@
|
|||||||
</a>
|
</a>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- ══════════════════════════════════════════════════════════
|
|
||||||
SECTION 8 - API publique
|
|
||||||
══════════════════════════════════════════════════════════ -->
|
|
||||||
<section class="section section-opensource">
|
|
||||||
<h2>API publique</h2>
|
|
||||||
<p class="section-text">
|
|
||||||
Les données de ce site internet sont publiques, toutes consultables via API directe, pour que des agents IA, des développeurs, ou d'autres sites puissent interroger la base sans passer par l'interface graphique.
|
|
||||||
</p>
|
|
||||||
<p class="section-text">
|
|
||||||
<strong>Données accessibles :</strong> organisations d'entraide, pratiques d'architecture d'écologie politique (graphe complet + projets), plateformes d'emploi, RAG et ses auteurs, et toutes autres données à venir.
|
|
||||||
</p>
|
|
||||||
<div class="api-copy-box">
|
|
||||||
<code class="api-url">https://api.trans-former.fr</code>
|
|
||||||
<button class="btn-copy" @click="copyApiUrl">{{ copied ? 'Copié !' : 'Copier' }}</button>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
|
||||||
|
|
||||||
useHead({ title: 'À propos - AEP' })
|
useHead({ title: 'À propos - AEP' })
|
||||||
|
|
||||||
const copied = ref(false)
|
|
||||||
function copyApiUrl() {
|
|
||||||
navigator.clipboard.writeText('https://api.trans-former.fr')
|
|
||||||
copied.value = true
|
|
||||||
setTimeout(() => { copied.value = false }, 2000)
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -209,14 +209,11 @@ function copyApiUrl() {
|
|||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background: var(--nav-bg);
|
background: var(--nav-bg);
|
||||||
padding: 1.5rem 1rem 5rem;
|
padding: 1.5rem 1rem 5rem;
|
||||||
overflow-x: hidden;
|
|
||||||
width: 100%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.apropos-inner {
|
.apropos-inner {
|
||||||
max-width: 720px;
|
max-width: 720px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
width: 100%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Retour ──────────────────────────────────────────────────────────────────── */
|
/* ── Retour ──────────────────────────────────────────────────────────────────── */
|
||||||
@@ -325,16 +322,13 @@ function copyApiUrl() {
|
|||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--nav-text);
|
color: var(--nav-text);
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.badge-detail {
|
.badge-detail {
|
||||||
font-size: 0.775rem;
|
font-size: 0.775rem;
|
||||||
color: var(--nav-text-muted);
|
color: var(--nav-text-muted);
|
||||||
line-height: 1.4;
|
white-space: nowrap;
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 560px) {
|
|
||||||
.badge-label { white-space: nowrap; }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 559px) {
|
@media (max-width: 559px) {
|
||||||
@@ -376,6 +370,49 @@ function copyApiUrl() {
|
|||||||
|
|
||||||
/* ── IA grid ─────────────────────────────────────────────────────────────────── */
|
/* ── 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 {
|
.ia-details {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -473,44 +510,6 @@ function copyApiUrl() {
|
|||||||
background: var(--nav-bg-alt);
|
background: var(--nav-bg-alt);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── API copy box ────────────────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
.api-copy-box {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
margin-top: 1rem;
|
|
||||||
padding: 0.5rem 0.75rem;
|
|
||||||
background: var(--nav-surface);
|
|
||||||
border: 1px solid rgba(26, 34, 56, 0.2);
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.api-url {
|
|
||||||
font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
color: var(--nav-primary-solid);
|
|
||||||
user-select: all;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-copy {
|
|
||||||
padding: 0.375rem 0.75rem;
|
|
||||||
background: var(--nav-bg-alt);
|
|
||||||
border: 1px solid rgba(26, 34, 56, 0.15);
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
color: var(--nav-text);
|
|
||||||
transition: background 0.15s;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-copy:hover {
|
|
||||||
background: var(--nav-primary-solid);
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Responsive général ──────────────────────────────────────────────────────── */
|
/* ── Responsive général ──────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
@media (max-width: 480px) {
|
||||||
|
|||||||
+27
-685
@@ -1,697 +1,39 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex h-full overflow-hidden" style="background: var(--nav-bg);">
|
<div class="flex flex-col items-center justify-center h-full gap-6" style="background: var(--nav-bg);">
|
||||||
|
<div class="text-center max-w-md px-6">
|
||||||
<!-- ═══════════════════════════════════════ SIDEBAR DESKTOP (>= 1024px) -->
|
|
||||||
<!-- Masquée en vue « Toutes les fiches » (B3-M3, décision Jules axe 2) :
|
|
||||||
le panneau EST la liste (+ ses propres chips hashtags), la sidebar
|
|
||||||
ferait doublon. IntentionBanner reste monté (Teleport, insensible au
|
|
||||||
v-show de son conteneur). -->
|
|
||||||
<div
|
<div
|
||||||
v-show="desktopMapView !== 'fiches'"
|
class="inline-flex items-center justify-center w-14 h-14 rounded-2xl mb-5"
|
||||||
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 />
|
|
||||||
|
|
||||||
<!-- Filtres familles + hashtags -->
|
|
||||||
<HashtagFilter
|
|
||||||
:allHashtags="allHashtags"
|
|
||||||
:selectedHashtags="selectedHashtags"
|
|
||||||
:selectedFamille="selectedFamille"
|
|
||||||
@update:selectedHashtags="selectedHashtags = $event"
|
|
||||||
@update:selectedFamille="selectedFamille = $event"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Separateur -->
|
|
||||||
<div style="height: 1px; background: var(--nav-bg-alt);"></div>
|
|
||||||
|
|
||||||
<!-- Barre de recherche -->
|
|
||||||
<div class="px-3 py-2" style="border-bottom: 1px solid var(--nav-bg-alt);">
|
|
||||||
<label class="sidebar-search-label" aria-label="Rechercher une structure">
|
|
||||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="sidebar-search-icon">
|
|
||||||
<circle cx="11" cy="11" r="8"/>
|
|
||||||
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
|
|
||||||
</svg>
|
|
||||||
<input
|
|
||||||
v-model="search"
|
|
||||||
type="search"
|
|
||||||
placeholder="Rechercher une structure..."
|
|
||||||
class="sidebar-search-input"
|
|
||||||
autocomplete="off"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
v-if="search"
|
|
||||||
type="button"
|
|
||||||
class="sidebar-search-clear"
|
|
||||||
aria-label="Effacer"
|
|
||||||
@click.stop="search = ''"
|
|
||||||
>
|
|
||||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
|
|
||||||
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Header compteur + reset -->
|
|
||||||
<div class="flex items-center justify-between px-4 py-2" style="border-bottom: 1px solid var(--nav-bg-alt);">
|
|
||||||
<span class="text-xs font-bold uppercase tracking-widest" style="color: var(--nav-text-muted);">
|
|
||||||
{{ filtered.length }} structure{{ filtered.length > 1 ? 's' : '' }}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
v-if="hasActiveFilters"
|
|
||||||
@click="resetFilters"
|
|
||||||
class="text-xs underline hover:opacity-70"
|
|
||||||
style="color: var(--nav-text-muted);"
|
|
||||||
>Effacer les filtres</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Liste fiches (sidebar entiere scroll - pas de scroll interne) -->
|
|
||||||
<div class="px-3 py-2 space-y-1.5">
|
|
||||||
<div v-if="pending" class="flex items-center justify-center py-8" style="color: var(--nav-text-muted);">
|
|
||||||
Chargement...
|
|
||||||
</div>
|
|
||||||
<div v-else-if="filtered.length === 0" class="text-center py-8">
|
|
||||||
<p class="text-xs" style="color: var(--nav-text-muted);">Aucun résultat</p>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-for="structure in filtered"
|
|
||||||
:key="structure.id"
|
|
||||||
class="rounded-lg px-3 py-2 cursor-pointer transition-all"
|
|
||||||
:style="selectedId === structure.id
|
|
||||||
? `background: var(--nav-bg-alt); border-left: 3px solid ${familleColor(structure.famille_principale)}; padding-left: 9px;`
|
|
||||||
: 'background: var(--nav-bg); border-left: 3px solid transparent; padding-left: 9px;'"
|
|
||||||
@click="onSelectStructure(structure.id)"
|
|
||||||
@mouseenter="hoveredId = structure.id"
|
|
||||||
@mouseleave="hoveredId = null"
|
|
||||||
>
|
|
||||||
<div class="flex items-start justify-between gap-1.5">
|
|
||||||
<span class="font-semibold text-sm leading-snug" style="color: var(--nav-text);">{{ structure.nom }}</span>
|
|
||||||
<span
|
|
||||||
class="shrink-0 w-2.5 h-2.5 rounded-full mt-1"
|
|
||||||
:style="`background: ${familleColor(structure.famille_principale)};`"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="mt-0.5 text-xs" style="color: var(--nav-text-muted);">{{ structure.type_principal }} - {{ structure.ville }}</div>
|
|
||||||
<ChipGroup v-if="structure.hashtags.length" class="mt-1" :aria-label="`Hashtags de ${structure.nom}`">
|
|
||||||
<Chip
|
|
||||||
v-for="tag in structure.hashtags.slice(0, 2)"
|
|
||||||
:key="tag"
|
|
||||||
as="span"
|
|
||||||
variant="fonction"
|
|
||||||
:label="tag"
|
|
||||||
/>
|
|
||||||
</ChipGroup>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- ═══════════════════════════════════════ ZONE CENTRALE (carte) -->
|
|
||||||
<main class="flex-1 flex flex-col overflow-hidden relative">
|
|
||||||
|
|
||||||
<!-- ── VUE DESKTOP : Onglets Métro/Outre-mer ── -->
|
|
||||||
<div class="hidden lg:flex lg:flex-1 lg:flex-col lg:overflow-hidden">
|
|
||||||
<!-- Onglets desktop -->
|
|
||||||
<div class="shrink-0 flex" style="background: var(--nav-surface); border-bottom: 1px solid var(--nav-bg-alt);">
|
|
||||||
<button
|
|
||||||
class="px-5 py-2 text-sm font-medium transition-colors"
|
|
||||||
:style="desktopMapView === 'metropole'
|
|
||||||
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
|
|
||||||
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
|
|
||||||
@click="setDesktopView('metropole')"
|
|
||||||
>Métropolitain</button>
|
|
||||||
<button
|
|
||||||
class="px-5 py-2 text-sm font-medium transition-colors"
|
|
||||||
:style="desktopMapView === 'outremer'
|
|
||||||
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
|
|
||||||
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
|
|
||||||
@click="setDesktopView('outremer')"
|
|
||||||
>Outre-mer</button>
|
|
||||||
<button
|
|
||||||
class="px-5 py-2 text-sm font-medium transition-colors"
|
|
||||||
:style="desktopMapView === 'graphe'
|
|
||||||
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
|
|
||||||
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
|
|
||||||
@click="setDesktopView('graphe')"
|
|
||||||
>Vue graphique</button>
|
|
||||||
<button
|
|
||||||
class="px-5 py-2 text-sm font-medium transition-colors"
|
|
||||||
:style="desktopMapView === 'fiches'
|
|
||||||
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
|
|
||||||
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
|
|
||||||
@click="setDesktopView('fiches')"
|
|
||||||
>Toutes les fiches</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Carte Métropole desktop -->
|
|
||||||
<div v-show="desktopMapView === 'metropole'" class="flex-1 flex flex-col overflow-hidden">
|
|
||||||
<div class="relative flex-1" style="min-height: 200px;">
|
|
||||||
<ClientOnly>
|
|
||||||
<NavMapV2
|
|
||||||
ref="navMapRef"
|
|
||||||
:structures="metropoleStructures"
|
|
||||||
:selectedId="selectedId"
|
|
||||||
@select-structure="onSelectStructure"
|
|
||||||
/>
|
|
||||||
<template #fallback>
|
|
||||||
<div
|
|
||||||
class="w-full h-full flex items-center justify-center"
|
|
||||||
style="background: var(--nav-bg-alt); color: var(--nav-text-muted);"
|
|
||||||
>
|
|
||||||
Chargement de la carte…
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</ClientOnly>
|
|
||||||
</div>
|
|
||||||
<ChatbotPlaceholder
|
|
||||||
@highlightOrgs="() => {}"
|
|
||||||
@applyHashtag="(tag) => { if (!selectedHashtags.includes(tag)) selectedHashtags = [...selectedHashtags, tag] }"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Carte Outre-mer desktop -->
|
|
||||||
<div v-show="desktopMapView === 'outremer'" class="flex-1 overflow-y-auto" style="background: var(--nav-bg);">
|
|
||||||
<ClientOnly>
|
|
||||||
<OutremerMap
|
|
||||||
:orgs="outremerOrgsLegacy"
|
|
||||||
:selectedId="selectedIdLegacyNum"
|
|
||||||
@select-org="() => {}"
|
|
||||||
/>
|
|
||||||
<template #fallback>
|
|
||||||
<div class="flex items-center justify-center h-full text-sm" style="color: var(--nav-text-muted);">
|
|
||||||
Chargement…
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</ClientOnly>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Vue graphique desktop -->
|
|
||||||
<div v-show="desktopMapView === 'graphe'" class="flex-1 overflow-hidden flex flex-col">
|
|
||||||
<div class="flex-1 overflow-hidden relative">
|
|
||||||
<ClientOnly>
|
|
||||||
<GraphView
|
|
||||||
:data="bifurcationData"
|
|
||||||
:allHashtags="allHashtags"
|
|
||||||
:active="desktopMapView === 'graphe'"
|
|
||||||
@select-structure="onSelectStructure"
|
|
||||||
/>
|
|
||||||
<template #fallback>
|
|
||||||
<div class="flex items-center justify-center h-full" style="color: var(--nav-text-muted);">
|
|
||||||
Chargement du graphe...
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</ClientOnly>
|
|
||||||
</div>
|
|
||||||
<ChatbotPlaceholder
|
|
||||||
@highlightOrgs="() => {}"
|
|
||||||
@applyHashtag="(tag) => { if (!selectedHashtags.includes(tag)) selectedHashtags = [...selectedHashtags, tag] }"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Panneau « Toutes les fiches » desktop (B3-M3) — la sidebar est
|
|
||||||
masquée dans cette vue (décision Jules axe 2), le panneau EST
|
|
||||||
la liste, avec ses propres chips hashtags sur le même état. -->
|
|
||||||
<div v-show="desktopMapView === 'fiches'" class="flex-1 flex flex-col overflow-hidden">
|
|
||||||
<FichesPanel
|
|
||||||
:fiches="allStructuresMini"
|
|
||||||
:hashtags="allHashtags"
|
|
||||||
:selected="selectedHashtags"
|
|
||||||
:query="search"
|
|
||||||
intention="Toutes les agences et réseaux du répertoire, filtrables par hashtag."
|
|
||||||
@update:selected="selectedHashtags = $event"
|
|
||||||
@open="(fiche) => onSelectStructure(String(fiche.id))"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- ── VUE MOBILE : Onglets Métro/Outre-mer/Graphique/Fiches + sheet swipable ── -->
|
|
||||||
<div class="lg:hidden shrink-0 flex" style="background: var(--nav-surface); border-bottom: 1px solid var(--nav-bg-alt);">
|
|
||||||
<button
|
|
||||||
class="flex-1 py-2 text-sm font-medium transition-colors"
|
|
||||||
:style="mobileMapView === 'metropole'
|
|
||||||
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
|
|
||||||
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
|
|
||||||
@click="setMobileView('metropole')"
|
|
||||||
>Métropolitain</button>
|
|
||||||
<button
|
|
||||||
class="flex-1 py-2 text-sm font-medium transition-colors"
|
|
||||||
:style="mobileMapView === 'outremer'
|
|
||||||
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
|
|
||||||
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
|
|
||||||
@click="setMobileView('outremer')"
|
|
||||||
>Outre-mer</button>
|
|
||||||
<button
|
|
||||||
class="flex-1 py-2 text-sm font-medium transition-colors"
|
|
||||||
:style="mobileMapView === 'graphe'
|
|
||||||
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
|
|
||||||
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
|
|
||||||
@click="setMobileView('graphe')"
|
|
||||||
>Graphe</button>
|
|
||||||
<button
|
|
||||||
class="flex-1 py-2 text-sm font-medium transition-colors"
|
|
||||||
:style="mobileMapView === 'fiches'
|
|
||||||
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
|
|
||||||
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
|
|
||||||
@click="setMobileView('fiches')"
|
|
||||||
>Fiches</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="lg:hidden flex-1 relative overflow-hidden">
|
|
||||||
<!-- Carte mobile Métropole -->
|
|
||||||
<div v-show="mobileMapView === 'metropole'" class="absolute inset-0">
|
|
||||||
<ClientOnly>
|
|
||||||
<NavMapV2
|
|
||||||
ref="navMapMobileRef"
|
|
||||||
:structures="metropoleStructures"
|
|
||||||
:selectedId="selectedId"
|
|
||||||
@select-structure="onSelectStructureMobile"
|
|
||||||
/>
|
|
||||||
<template #fallback>
|
|
||||||
<div class="w-full h-full flex items-center justify-center" style="background: var(--nav-bg-alt); color: var(--nav-text-muted);">
|
|
||||||
Chargement de la carte…
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</ClientOnly>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Carte mobile Outre-mer -->
|
|
||||||
<div v-show="mobileMapView === 'outremer'" class="absolute inset-0 overflow-y-auto" style="background: var(--nav-bg);">
|
|
||||||
<ClientOnly>
|
|
||||||
<OutremerMap
|
|
||||||
:orgs="outremerOrgsLegacy"
|
|
||||||
:selectedId="selectedIdLegacyNum"
|
|
||||||
@select-org="() => {}"
|
|
||||||
/>
|
|
||||||
<template #fallback>
|
|
||||||
<div class="flex items-center justify-center h-48" style="color: var(--nav-text-muted);">
|
|
||||||
Chargement…
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</ClientOnly>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Vue graphique mobile -->
|
|
||||||
<div v-show="mobileMapView === 'graphe'" class="absolute inset-0 overflow-hidden" style="background: var(--nav-bg);">
|
|
||||||
<ClientOnly>
|
|
||||||
<GraphView
|
|
||||||
:data="bifurcationData"
|
|
||||||
:allHashtags="allHashtags"
|
|
||||||
:active="mobileMapView === 'graphe'"
|
|
||||||
@select-structure="onSelectStructureMobile"
|
|
||||||
/>
|
|
||||||
<template #fallback>
|
|
||||||
<div class="flex items-center justify-center h-48" style="color: var(--nav-text-muted);">
|
|
||||||
Chargement du graphe…
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</ClientOnly>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Zone « Fiches » mobile (B3-M3) — pleine hauteur sous la barre d'onglets,
|
|
||||||
pas de carte dessous donc pas de sheet demi-hauteur (même logique que
|
|
||||||
la vue graphe qui masque déjà le MobileSheet). Fix 2026-09-25 : la
|
|
||||||
version précédente empilait un second MobileSheet (état half par
|
|
||||||
défaut) au-dessus d'un fond neutre plein écran, laissant ~45% de
|
|
||||||
hauteur vide au-dessus du panneau. -->
|
|
||||||
<div
|
|
||||||
v-show="mobileMapView === 'fiches'"
|
|
||||||
class="absolute inset-0 flex flex-col overflow-hidden"
|
|
||||||
style="background: var(--nav-bg-alt);"
|
style="background: var(--nav-bg-alt);"
|
||||||
>
|
>
|
||||||
<FichesPanel
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="color: var(--nav-text-muted);">
|
||||||
:fiches="allStructuresMini"
|
<rect x="3" y="3" width="7" height="7"/>
|
||||||
:hashtags="allHashtags"
|
<rect x="14" y="3" width="7" height="7"/>
|
||||||
:selected="selectedHashtags"
|
<rect x="14" y="14" width="7" height="7"/>
|
||||||
:query="search"
|
<rect x="3" y="14" width="7" height="7"/>
|
||||||
intention="Toutes les agences et réseaux du répertoire, filtrables par hashtag."
|
</svg>
|
||||||
@update:selected="selectedHashtags = $event"
|
|
||||||
@open="(fiche) => onSelectStructureMobile(String(fiche.id))"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
<h1 class="text-2xl font-bold mb-3" style="color: var(--nav-text);">Agences Inspirantes</h1>
|
||||||
<!-- Bottom sheet swipable (masqué en vue graphique et en vue fiches) -->
|
<p class="text-sm leading-relaxed mb-6" style="color: var(--nav-text-muted);">
|
||||||
<ClientOnly v-if="mobileMapView !== 'graphe' && mobileMapView !== 'fiches'">
|
Cette section répertoriera les agences d'architecture qui incarnent une pratique engagée — écologie politique, auto-construction, architectures vernaculaires, sobriété.
|
||||||
<MobileSheet :resultCount="filtered.length" :pending="pending">
|
|
||||||
<!-- Bandeau intention mobile -->
|
|
||||||
<div class="px-3 py-2" style="background: var(--bifurc-banner-bg, #faf8f5); border-bottom: 1px solid var(--bifurc-banner-border, #e0d8cc);">
|
|
||||||
<p class="text-xs leading-relaxed" style="color: var(--bifurc-banner-text, #2c2416); margin: 0;">
|
|
||||||
120 réseaux, collectifs et agences où des pensées écologiques deviennent des pratiques d'architecture.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
<p class="text-xs font-semibold uppercase tracking-widest mb-6" style="color: var(--nav-text-muted); opacity: 0.6;">
|
||||||
|
Bientôt disponible
|
||||||
<!-- Filtres hashtags mobile -->
|
|
||||||
<div class="px-3 py-2" style="border-bottom: 1px solid var(--nav-bg-alt);">
|
|
||||||
<HashtagFilter
|
|
||||||
:allHashtags="allHashtags"
|
|
||||||
:selectedHashtags="selectedHashtags"
|
|
||||||
:selectedFamille="selectedFamille"
|
|
||||||
@update:selectedHashtags="selectedHashtags = $event"
|
|
||||||
@update:selectedFamille="selectedFamille = $event"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Barre recherche mobile -->
|
|
||||||
<div class="px-3 pt-2 pb-2" style="border-bottom: 1px solid var(--nav-bg-alt);">
|
|
||||||
<label class="mobile-search-label" aria-label="Rechercher une structure">
|
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="color: var(--nav-text-muted); flex-shrink: 0;">
|
|
||||||
<circle cx="11" cy="11" r="8"/>
|
|
||||||
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
|
|
||||||
</svg>
|
|
||||||
<input
|
|
||||||
v-model="search"
|
|
||||||
type="search"
|
|
||||||
placeholder="Rechercher…"
|
|
||||||
class="mobile-search-input"
|
|
||||||
autocomplete="off"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
v-if="search"
|
|
||||||
type="button"
|
|
||||||
class="mobile-search-clear"
|
|
||||||
aria-label="Effacer"
|
|
||||||
@click.stop="search = ''"
|
|
||||||
>
|
|
||||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
|
|
||||||
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</label>
|
|
||||||
<button
|
|
||||||
v-if="hasActiveFilters"
|
|
||||||
@click="resetFilters"
|
|
||||||
class="mt-1 text-xs"
|
|
||||||
style="color: var(--nav-text-muted); text-decoration: underline;"
|
|
||||||
>Effacer les filtres</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Liste fiches mobile -->
|
|
||||||
<div class="px-3 py-2">
|
|
||||||
<div class="text-xs font-bold uppercase tracking-wide mb-2" style="color: var(--nav-text-muted);">
|
|
||||||
{{ filtered.length }} structure{{ filtered.length > 1 ? 's' : '' }}
|
|
||||||
</div>
|
|
||||||
<div v-if="pending" class="flex items-center justify-center py-8" style="color: var(--nav-text-muted);">
|
|
||||||
Chargement des fiches…
|
|
||||||
</div>
|
|
||||||
<div v-else-if="filtered.length === 0" class="text-center py-8">
|
|
||||||
<p class="text-sm mb-2" style="color: var(--nav-text-muted);">Aucun résultat pour ces filtres.</p>
|
|
||||||
<button @click="resetFilters" class="text-sm underline" style="color: var(--nav-primary-solid);">
|
|
||||||
Effacer les filtres
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="space-y-2">
|
|
||||||
<div
|
|
||||||
v-for="structure in filtered"
|
|
||||||
:key="structure.id"
|
|
||||||
class="block rounded-lg p-3 transition-all cursor-pointer"
|
|
||||||
:style="selectedId === structure.id
|
|
||||||
? `background: var(--nav-bg-alt); border-left: 3px solid ${familleColor(structure.famille_principale)};`
|
|
||||||
: 'background: var(--nav-surface); border-left: 3px solid transparent;'"
|
|
||||||
@click="onSelectStructureMobile(structure.id)"
|
|
||||||
>
|
|
||||||
<div class="flex items-start justify-between gap-2">
|
|
||||||
<span class="font-semibold text-sm leading-snug" style="color: var(--nav-text);">{{ structure.nom }}</span>
|
|
||||||
<span
|
|
||||||
class="shrink-0 w-2.5 h-2.5 rounded-full mt-1"
|
|
||||||
:style="`background: ${familleColor(structure.famille_principale)};`"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="mt-0.5 text-xs" style="color: var(--nav-text-muted);">{{ structure.type_principal }} · {{ structure.ville }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</MobileSheet>
|
|
||||||
</ClientOnly>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<!-- ═══════════════════════════════════════ MODAL FICHE V2 (desktop) -->
|
|
||||||
<FicheModalV2
|
|
||||||
v-model="ficheModalOpen"
|
|
||||||
:structureId="ficheModalId"
|
|
||||||
:data="bifurcationData"
|
|
||||||
@update:structureId="ficheModalId = $event"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- ═══════════════════════════════════════ BOUTON CHATBOT FLOTTANT (mobile) -->
|
|
||||||
<button
|
|
||||||
class="lg:hidden fixed bottom-6 right-4 z-[1000] flex items-center gap-2 px-4 rounded-full shadow-lg"
|
|
||||||
style="
|
|
||||||
height: 48px;
|
|
||||||
background: var(--nav-primary);
|
|
||||||
opacity: 0.92;
|
|
||||||
color: var(--nav-text-on-primary);
|
|
||||||
box-shadow: 0 4px 16px rgba(26,34,56,0.25);
|
|
||||||
font-family: var(--nav-font);
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-weight: 600;
|
|
||||||
"
|
|
||||||
aria-label="Ouvrir l'assistant Chatbot"
|
|
||||||
@click="chatbotOpen = true"
|
|
||||||
>
|
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
||||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
|
|
||||||
</svg>
|
|
||||||
<span>Chatbot</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- ═══════════════════════════════════════ CHATBOT BOTTOM SHEET (mobile) -->
|
|
||||||
<ChatbotReseaux
|
|
||||||
:modelValue="chatbotOpen"
|
|
||||||
@update:modelValue="chatbotOpen = $event"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- ═══════════════════════════════════════ POP-UP MISSION RÉSEAUX AEP -->
|
|
||||||
<button
|
|
||||||
class="reseaux-info-btn"
|
|
||||||
type="button"
|
|
||||||
@click="missionOpen = true"
|
|
||||||
aria-label="À propos des réseaux AEP cartographiés"
|
|
||||||
title="À propos de cette carte"
|
|
||||||
>
|
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
||||||
<circle cx="12" cy="12" r="10"/>
|
|
||||||
<line x1="12" y1="16" x2="12" y2="12"/>
|
|
||||||
<line x1="12" y1="8" x2="12.01" y2="8"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<MissionPopup
|
|
||||||
:modelValue="missionOpen"
|
|
||||||
@update:modelValue="missionOpen = $event"
|
|
||||||
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.
|
|
||||||
</p>
|
</p>
|
||||||
<p class="mission-text">
|
<NuxtLink
|
||||||
Six familles structurent la cartographie : militants, agences engagées, collectifs de production, ressources communes, recherche, formations alternatives. Filtre par hashtag, ouvre la fiche d'une structure, navigue le graphe (3<sup>e</sup> onglet) pour voir les affinités. Si tu animes ou connais un réseau qui devrait y être : <NuxtLink to="/contribuer" @click.stop>propose-le</NuxtLink>.
|
to="/"
|
||||||
</p>
|
class="inline-flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-semibold transition-all hover:opacity-80"
|
||||||
</MissionPopup>
|
style="background: var(--nav-primary); color: var(--nav-text-on-primary);"
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" aria-hidden="true">
|
||||||
|
<line x1="19" y1="12" x2="5" y2="12"/>
|
||||||
|
<polyline points="12 19 5 12 12 5"/>
|
||||||
|
</svg>
|
||||||
|
Retour à l'écosystème
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { ReseauxBifurcationData, StructureV2 } from '~/types/structure-v2'
|
useHead({ title: 'Agences Inspirantes — AEP (bientôt disponible)' })
|
||||||
|
|
||||||
// ── Couleurs familles ──────────────────────────────────────────────────────
|
|
||||||
const FAMILLE_COLORS: Record<number, string> = {
|
|
||||||
1: '#a85d3e',
|
|
||||||
2: '#c4a472',
|
|
||||||
3: '#d4a017',
|
|
||||||
4: '#5a7a4a',
|
|
||||||
5: '#3d6a8c',
|
|
||||||
6: '#6b3fa0',
|
|
||||||
}
|
|
||||||
|
|
||||||
function familleColor(f: number): string {
|
|
||||||
return FAMILLE_COLORS[f] ?? '#888'
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── État UI ────────────────────────────────────────────────────────────────
|
|
||||||
type MapView = 'metropole' | 'outremer' | 'graphe' | 'fiches'
|
|
||||||
|
|
||||||
const route = useRoute()
|
|
||||||
const router = useRouter()
|
|
||||||
|
|
||||||
const selectedId = ref<string | null>(null)
|
|
||||||
const hoveredId = ref<string | null>(null)
|
|
||||||
const ficheModalOpen = ref(false)
|
|
||||||
const ficheModalId = ref<string | null>(null)
|
|
||||||
const chatbotOpen = ref(false)
|
|
||||||
const mobileMapView = ref<MapView>((route.query.vue as string) === 'fiches' ? 'fiches' : 'metropole')
|
|
||||||
const desktopMapView = ref<MapView>((route.query.vue as string) === 'fiches' ? 'fiches' : 'metropole')
|
|
||||||
const missionOpen = ref(false)
|
|
||||||
|
|
||||||
// Sync URL — B3-M3 : pose/retire ?vue=fiches uniquement (agences.vue n'avait
|
|
||||||
// aucune synchronisation URL avant B3 pour search/famille/hashtags/vue ; hors
|
|
||||||
// périmètre de cette mission de l'étendre, seul ?vue=fiches est ajouté ici).
|
|
||||||
function setDesktopView(v: MapView) {
|
|
||||||
desktopMapView.value = v
|
|
||||||
syncViewUrl()
|
|
||||||
}
|
|
||||||
function setMobileView(v: MapView) {
|
|
||||||
mobileMapView.value = v
|
|
||||||
syncViewUrl()
|
|
||||||
}
|
|
||||||
function syncViewUrl() {
|
|
||||||
const q: Record<string, string> = { ...(route.query as Record<string, string>) }
|
|
||||||
if (desktopMapView.value === 'fiches' || mobileMapView.value === 'fiches') {
|
|
||||||
q.vue = 'fiches'
|
|
||||||
} else {
|
|
||||||
delete q.vue
|
|
||||||
}
|
|
||||||
router.replace({ query: Object.keys(q).length ? q : undefined })
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
try {
|
|
||||||
if (!localStorage.getItem('aep_reseaux_seen')) {
|
|
||||||
missionOpen.value = true
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Filtres
|
|
||||||
const search = ref('')
|
|
||||||
const selectedFamille = ref<number | null>(null)
|
|
||||||
const selectedHashtags = ref<string[]>([])
|
|
||||||
|
|
||||||
// Refs cartes
|
|
||||||
const navMapRef = ref<any>(null)
|
|
||||||
const navMapMobileRef = ref<any>(null)
|
|
||||||
|
|
||||||
// ── Données V2 - JSON statique ─────────────────────────────────────────────
|
|
||||||
const bifurcationData = ref<ReseauxBifurcationData | null>(null)
|
|
||||||
const pending = ref(true)
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
try {
|
|
||||||
bifurcationData.value = await $fetch<ReseauxBifurcationData>('/data/reseaux-bifurcation.json')
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Erreur chargement reseaux-bifurcation.json', e)
|
|
||||||
} finally {
|
|
||||||
pending.value = false
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const structures = computed<StructureV2[]>(() => bifurcationData.value?.structures ?? [])
|
|
||||||
|
|
||||||
// Tous les hashtags uniques triés
|
|
||||||
const allHashtags = computed<string[]>(() => {
|
|
||||||
const set = new Set<string>()
|
|
||||||
structures.value.forEach(s => s.hashtags.forEach(h => set.add(h)))
|
|
||||||
return Array.from(set).sort()
|
|
||||||
})
|
|
||||||
|
|
||||||
// ── Filtrage ───────────────────────────────────────────────────────────────
|
|
||||||
const filtered = computed<StructureV2[]>(() => {
|
|
||||||
let result = structures.value
|
|
||||||
|
|
||||||
// Filtre texte
|
|
||||||
if (search.value.trim()) {
|
|
||||||
const q = search.value.toLowerCase()
|
|
||||||
result = result.filter(
|
|
||||||
s =>
|
|
||||||
s.nom.toLowerCase().includes(q) ||
|
|
||||||
s.ville.toLowerCase().includes(q) ||
|
|
||||||
s.description_courte.toLowerCase().includes(q) ||
|
|
||||||
s.hashtags.some(h => h.toLowerCase().includes(q))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filtre famille - F6 = badge_f6_recherche_politique, pas famille_principale
|
|
||||||
if (selectedFamille.value !== null) {
|
|
||||||
if (selectedFamille.value === 6) {
|
|
||||||
result = result.filter(s => (s.badges as any)?.f6_recherche_politique === true)
|
|
||||||
} else {
|
|
||||||
result = result.filter(
|
|
||||||
s => s.famille_principale === selectedFamille.value ||
|
|
||||||
(s.familles_secondaires ?? []).includes(selectedFamille.value!)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filtre hashtags (AND logique si plusieurs)
|
|
||||||
if (selectedHashtags.value.length) {
|
|
||||||
result = result.filter(
|
|
||||||
s => selectedHashtags.value.every(h => s.hashtags.includes(h))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
})
|
|
||||||
|
|
||||||
const hasActiveFilters = computed(
|
|
||||||
() => !!search.value || selectedFamille.value !== null || selectedHashtags.value.length > 0
|
|
||||||
)
|
|
||||||
|
|
||||||
function resetFilters() {
|
|
||||||
search.value = ''
|
|
||||||
selectedFamille.value = null
|
|
||||||
selectedHashtags.value = []
|
|
||||||
}
|
|
||||||
|
|
||||||
// Structures métropole (pays != DOM-TOM, et avec coordonnées)
|
|
||||||
// Pour simplifier : toutes les structures (la carte gère les sans-coords)
|
|
||||||
const metropoleStructures = computed<StructureV2[]>(() => filtered.value)
|
|
||||||
|
|
||||||
// Panneau « Toutes les fiches » (B3-M3) — mêmes structures que `filtered`,
|
|
||||||
// sans filtrer par géolocalisation (adaptateur mutualisé avec `/`).
|
|
||||||
const { toMini } = useFicheAdapter()
|
|
||||||
const allStructuresMini = computed(() => filtered.value.map((s) => toMini(s)))
|
|
||||||
|
|
||||||
// Outre-mer : pas de structures V2 DOM-TOM pour l'instant - garder le composant existant vide
|
|
||||||
// OutremerMap attend le format Org legacy - on passe un tableau vide
|
|
||||||
const outremerOrgsLegacy = computed(() => [])
|
|
||||||
const selectedIdLegacyNum = computed(() => null)
|
|
||||||
|
|
||||||
// ── Sélection ─────────────────────────────────────────────────────────────
|
|
||||||
function onSelectStructure(id: string) {
|
|
||||||
selectedId.value = selectedId.value === id ? null : id
|
|
||||||
if (typeof window !== 'undefined' && window.innerWidth >= 1024) {
|
|
||||||
ficheModalId.value = id
|
|
||||||
ficheModalOpen.value = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function onSelectStructureMobile(id: string) {
|
|
||||||
selectedId.value = id
|
|
||||||
ficheModalId.value = id
|
|
||||||
ficheModalOpen.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
useHead({ title: "AEP - Réseaux de bifurcation architecturale" })
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.reseaux-info-btn {
|
|
||||||
position: fixed;
|
|
||||||
bottom: 24px;
|
|
||||||
left: 16px;
|
|
||||||
z-index: 1000;
|
|
||||||
width: 36px;
|
|
||||||
height: 36px;
|
|
||||||
border-radius: 50%;
|
|
||||||
border: none;
|
|
||||||
background: var(--nav-surface);
|
|
||||||
color: var(--nav-text-muted);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
box-shadow: 0 2px 12px rgba(26,34,56,0.18);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: opacity 0.15s, transform 0.1s;
|
|
||||||
}
|
|
||||||
.reseaux-info-btn:hover { opacity: 0.85; transform: translateY(-1px); color: var(--nav-text); }
|
|
||||||
|
|
||||||
@media (min-width: 1024px) {
|
|
||||||
.reseaux-info-btn { bottom: 16px; left: 340px; }
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -1,550 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="codev-carto">
|
|
||||||
|
|
||||||
<header class="carto-header">
|
|
||||||
<h1>Carto entraide</h1>
|
|
||||||
<p class="carto-subtitle">
|
|
||||||
<template v-if="pending">Chargement...</template>
|
|
||||||
<template v-else>
|
|
||||||
{{ fiches.length }} fiche{{ fiches.length !== 1 ? 's' : '' }} - clique sur un nom pour voir le detail
|
|
||||||
</template>
|
|
||||||
</p>
|
|
||||||
<NuxtLink to="/codev/qr" class="qr-link" title="QR Code">[ QR ]</NuxtLink>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div class="codev-tabs">
|
|
||||||
<button :class="{ active: tab === 'carto' }" @click="tab = 'carto'" type="button">Carto</button>
|
|
||||||
<button :class="{ active: tab === 'annuaire' }" @click="tab = 'annuaire'" type="button">Annuaire</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="tab === 'carto'">
|
|
||||||
<div class="show-labels-bar">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
:class="{ active: showLabels }"
|
|
||||||
@click="showLabels = !showLabels"
|
|
||||||
>
|
|
||||||
{{ showLabels ? 'Masquer besoins/offres' : 'Montrer besoins/offres' }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ClientOnly>
|
|
||||||
<CodevGraph
|
|
||||||
:fiches="fiches"
|
|
||||||
:matches="matches"
|
|
||||||
:mode="mode"
|
|
||||||
:show-labels="showLabels"
|
|
||||||
@select-fiche="onSelectFiche"
|
|
||||||
/>
|
|
||||||
<template #fallback>
|
|
||||||
<div class="graph-fallback">Chargement du graphe...</div>
|
|
||||||
</template>
|
|
||||||
</ClientOnly>
|
|
||||||
|
|
||||||
<!-- Bandeau info mode actif -->
|
|
||||||
<div v-if="mode !== 'none'" class="mode-banner">
|
|
||||||
<span>
|
|
||||||
Mode {{ MODE_LABELS[mode] }} actif -
|
|
||||||
{{ matches.length }} connexion{{ matches.length !== 1 ? 's' : '' }} trouvee{{ matches.length !== 1 ? 's' : '' }}.
|
|
||||||
</span>
|
|
||||||
<button class="banner-clear" @click="setMode('none')" type="button">Effacer</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Boutons matching -->
|
|
||||||
<div class="matching-controls">
|
|
||||||
<button
|
|
||||||
:class="{ active: mode === 'solution' }"
|
|
||||||
style="--mode-color: #22c55e"
|
|
||||||
@click="setMode('solution')"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
Solution
|
|
||||||
<span class="hint">besoin - compétence</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
:class="{ active: mode === 'alliance' }"
|
|
||||||
style="--mode-color: #f97316"
|
|
||||||
@click="setMode('alliance')"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
Alliance
|
|
||||||
<span class="hint">besoins partagés</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
v-if="mode !== 'none'"
|
|
||||||
class="reset"
|
|
||||||
@click="setMode('none')"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
Effacer
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else-if="tab === 'annuaire'" class="annuaire-wrap">
|
|
||||||
|
|
||||||
<div v-if="fiches.length === 0" class="list-empty">
|
|
||||||
Aucune fiche. <NuxtLink to="/codev/fiche">Ajouter la mienne</NuxtLink>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else class="annuaire-scroll">
|
|
||||||
<table class="annuaire-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th class="col-nom">Prénom</th>
|
|
||||||
<th class="col-besoin">Besoin</th>
|
|
||||||
<th class="col-offre">Ce que j'offre</th>
|
|
||||||
<th v-if="isAdmin" class="col-actions"></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr v-for="f in fiches" :key="f.id" @click="navigateTo(`/codev/fiche?id=${f.id}`)" class="annuaire-row">
|
|
||||||
<td class="col-nom">{{ f.nom }}</td>
|
|
||||||
<td class="col-besoin">{{ f.besoin }}</td>
|
|
||||||
<td class="col-offre">{{ f.offre }}</td>
|
|
||||||
<td v-if="isAdmin" class="col-actions">
|
|
||||||
<button @click.stop="deleteFiche(f.id)" class="delete-btn" type="button" title="Supprimer">✕</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p class="annuaire-hint">Clique sur une ligne pour modifier la fiche</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- FAB ajouter une fiche -->
|
|
||||||
<NuxtLink to="/codev/fiche" class="fab-add" title="Ajouter ma fiche" aria-label="Ajouter une fiche">
|
|
||||||
+
|
|
||||||
</NuxtLink>
|
|
||||||
|
|
||||||
<Transition name="sheet">
|
|
||||||
<div v-if="selectedFiche" class="bottom-sheet" @click.self="selectedFiche = null">
|
|
||||||
<div class="sheet-content">
|
|
||||||
<div class="sheet-handle"></div>
|
|
||||||
<div class="sheet-name">{{ selectedFiche.nom }}</div>
|
|
||||||
<div class="sheet-section">
|
|
||||||
<span class="sheet-label">Besoin</span>
|
|
||||||
<p class="sheet-text">{{ selectedFiche.besoin }}</p>
|
|
||||||
</div>
|
|
||||||
<div class="sheet-section">
|
|
||||||
<span class="sheet-label">Ce que j'apporte</span>
|
|
||||||
<p class="sheet-text">{{ selectedFiche.offre }}</p>
|
|
||||||
</div>
|
|
||||||
<div class="sheet-tags" v-if="selectedFiche.hashtags.length">
|
|
||||||
<span v-for="t in selectedFiche.hashtags" :key="t" class="sheet-tag">#{{ t }}</span>
|
|
||||||
</div>
|
|
||||||
<NuxtLink :to="`/codev/fiche?id=${selectedFiche.id}`" class="sheet-edit-btn">Modifier cette fiche</NuxtLink>
|
|
||||||
<button class="sheet-close" @click="selectedFiche = null" type="button">Fermer</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Transition>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import type { CodevFiche, CodevMatch } from '~/types/codev'
|
|
||||||
import { computeMatches } from '~/utils/codev/matching'
|
|
||||||
|
|
||||||
useHead({ title: 'Carto - Co-developpement' })
|
|
||||||
|
|
||||||
const { data, pending, refresh } = await useFetch<{ list: CodevFiche[] }>('/api/codev/fiches')
|
|
||||||
const fiches = computed(() => data.value?.list ?? [])
|
|
||||||
|
|
||||||
const matches = ref<CodevMatch[]>([])
|
|
||||||
const mode = ref<'none' | 'solution' | 'alliance' | 'surprise'>('none')
|
|
||||||
const showLabels = ref(false)
|
|
||||||
const tab = ref<'carto' | 'annuaire'>('carto')
|
|
||||||
const selectedFiche = ref<CodevFiche | null>(null)
|
|
||||||
const isMobileView = typeof window !== 'undefined' ? window.innerWidth < 600 : false
|
|
||||||
|
|
||||||
const isAdmin = ref(false)
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
try {
|
|
||||||
const r = await $fetch<{ admin: boolean }>('/api/codev/me')
|
|
||||||
isAdmin.value = r.admin
|
|
||||||
} catch { isAdmin.value = false }
|
|
||||||
})
|
|
||||||
|
|
||||||
const MODE_LABELS: Record<string, string> = {
|
|
||||||
solution: 'Solution',
|
|
||||||
alliance: 'Alliance',
|
|
||||||
surprise: 'Surprise',
|
|
||||||
}
|
|
||||||
|
|
||||||
function setMode(newMode: 'none' | 'solution' | 'alliance' | 'surprise') {
|
|
||||||
mode.value = newMode
|
|
||||||
if (newMode === 'none') {
|
|
||||||
matches.value = []
|
|
||||||
} else {
|
|
||||||
matches.value = computeMatches(fiches.value, newMode)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function onSelectFiche(id: number) {
|
|
||||||
if (isMobileView) {
|
|
||||||
selectedFiche.value = fiches.value.find(f => f.id === id) ?? null
|
|
||||||
} else {
|
|
||||||
navigateTo(`/codev/fiche?id=${id}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteFiche(id: number) {
|
|
||||||
if (!confirm('Supprimer la fiche ?')) return
|
|
||||||
await $fetch(`/api/codev/fiches/${id}`, { method: 'DELETE' })
|
|
||||||
await refresh()
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.codev-carto {
|
|
||||||
min-height: 100vh;
|
|
||||||
background: var(--nav-bg, #fafafa);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
padding: 1.25rem 1rem 2rem;
|
|
||||||
gap: 1rem;
|
|
||||||
max-width: 100%;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── En-tete ── */
|
|
||||||
|
|
||||||
.carto-header {
|
|
||||||
text-align: center;
|
|
||||||
padding-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.carto-header h1 {
|
|
||||||
font-size: 1.5rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--nav-text, #1a1a2e);
|
|
||||||
margin: 0 0 0.375rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.carto-subtitle {
|
|
||||||
font-size: 0.9rem;
|
|
||||||
color: var(--nav-text-muted, #6b7280);
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Fallback ── */
|
|
||||||
|
|
||||||
.graph-fallback {
|
|
||||||
width: 100%;
|
|
||||||
height: 70vh;
|
|
||||||
min-height: 320px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
color: var(--nav-text-muted, #6b7280);
|
|
||||||
font-size: 0.9rem;
|
|
||||||
background: var(--nav-bg-alt, #f3f4f6);
|
|
||||||
border-radius: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Bandeau mode actif ── */
|
|
||||||
|
|
||||||
.mode-banner {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 0.75rem;
|
|
||||||
padding: 0.5rem 0.875rem;
|
|
||||||
background: #f0fdf4;
|
|
||||||
border: 1px solid #bbf7d0;
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
color: #166534;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.banner-clear {
|
|
||||||
font-size: 0.8rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #166534;
|
|
||||||
background: transparent;
|
|
||||||
border: 1px solid #166534;
|
|
||||||
border-radius: 6px;
|
|
||||||
padding: 0.2rem 0.6rem;
|
|
||||||
cursor: pointer;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.banner-clear:hover {
|
|
||||||
background: #166534;
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Boutons matching ── */
|
|
||||||
|
|
||||||
.matching-controls {
|
|
||||||
position: sticky;
|
|
||||||
bottom: 0;
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 12px;
|
|
||||||
background: rgba(255, 255, 255, 0.95);
|
|
||||||
backdrop-filter: blur(8px);
|
|
||||||
border-top: 1px solid #e5e7eb;
|
|
||||||
margin: 0 -1rem -2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.matching-controls button {
|
|
||||||
flex: 1;
|
|
||||||
padding: 12px 8px;
|
|
||||||
border: 1px solid #d0d4dc;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: white;
|
|
||||||
font-size: 14px;
|
|
||||||
cursor: pointer;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: 2px;
|
|
||||||
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.matching-controls button .hint {
|
|
||||||
font-size: 11px;
|
|
||||||
color: #6b7280;
|
|
||||||
font-weight: normal;
|
|
||||||
}
|
|
||||||
|
|
||||||
.matching-controls button.active {
|
|
||||||
background: var(--mode-color, #1B4436);
|
|
||||||
color: white;
|
|
||||||
border-color: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.matching-controls button.active .hint {
|
|
||||||
color: rgba(255, 255, 255, 0.8);
|
|
||||||
}
|
|
||||||
|
|
||||||
.matching-controls button.reset {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
padding: 12px 16px;
|
|
||||||
background: #f3f4f6;
|
|
||||||
border-color: #d0d4dc;
|
|
||||||
color: #374151;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.matching-controls button.reset:hover {
|
|
||||||
background: #e5e7eb;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 500px) {
|
|
||||||
.matching-controls {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(2, 1fr);
|
|
||||||
margin: 0 -0.75rem -1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.matching-controls button.reset {
|
|
||||||
grid-column: span 2;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Toggle besoins/offres ── */
|
|
||||||
|
|
||||||
.show-labels-bar {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.show-labels-bar button {
|
|
||||||
border: 1px solid #d0d4dc;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 8px 16px;
|
|
||||||
background: white;
|
|
||||||
font-size: 13px;
|
|
||||||
cursor: pointer;
|
|
||||||
color: #374151;
|
|
||||||
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.show-labels-bar button.active {
|
|
||||||
background: #1B4436;
|
|
||||||
color: white;
|
|
||||||
border-color: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── FAB ajouter ── */
|
|
||||||
|
|
||||||
.fab-add {
|
|
||||||
position: fixed;
|
|
||||||
bottom: 80px;
|
|
||||||
right: 16px;
|
|
||||||
width: 48px;
|
|
||||||
height: 48px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: #1B4436;
|
|
||||||
color: white;
|
|
||||||
font-size: 28px;
|
|
||||||
font-weight: 300;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
text-decoration: none;
|
|
||||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.18);
|
|
||||||
z-index: 100;
|
|
||||||
transition: transform 0.15s, opacity 0.15s;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fab-add:hover {
|
|
||||||
transform: scale(1.08);
|
|
||||||
opacity: 0.92;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Tabs ── */
|
|
||||||
|
|
||||||
.codev-tabs { display: flex; gap: 4px; background: #f3f4f6; border-radius: 10px; padding: 4px; }
|
|
||||||
.codev-tabs button { flex: 1; padding: 8px 4px; border: none; border-radius: 7px; background: transparent; font-size: 0.875rem; font-weight: 500; cursor: pointer; color: #6b7280; transition: all 0.15s; }
|
|
||||||
.codev-tabs button.active { background: white; color: #1a1a2e; font-weight: 600; box-shadow: 0 1px 3px rgba(0,0,0,0.08); }
|
|
||||||
|
|
||||||
/* ── List view ── */
|
|
||||||
|
|
||||||
.list-view { display: flex; flex-direction: column; gap: 12px; padding: 8px 0; }
|
|
||||||
.list-card { background: white; border: 1px solid #e5e7eb; border-radius: 10px; padding: 14px 16px; display: flex; flex-direction: column; gap: 6px; }
|
|
||||||
.list-card-name { font-weight: 700; font-size: 0.95rem; color: #1a1a2e; }
|
|
||||||
.list-card-text { font-size: 0.875rem; color: #4b5563; margin: 0; line-height: 1.5; }
|
|
||||||
.list-card-link { font-size: 0.8rem; color: #1B4436; text-decoration: none; align-self: flex-end; }
|
|
||||||
.list-empty { text-align: center; color: #6b7280; font-size: 0.9rem; }
|
|
||||||
|
|
||||||
/* ── Bottom sheet ── */
|
|
||||||
|
|
||||||
.bottom-sheet { position: fixed; inset: 0; background: rgba(0,0,0,0.4); z-index: 200; display: flex; align-items: flex-end; }
|
|
||||||
.sheet-content { background: white; border-radius: 16px 16px 0 0; padding: 16px 20px 32px; width: 100%; display: flex; flex-direction: column; gap: 12px; max-height: 80vh; overflow-y: auto; }
|
|
||||||
.sheet-handle { width: 36px; height: 4px; background: #d1d5db; border-radius: 2px; align-self: center; margin-bottom: 4px; }
|
|
||||||
.sheet-name { font-size: 1.1rem; font-weight: 700; color: #1a1a2e; }
|
|
||||||
.sheet-section { display: flex; flex-direction: column; gap: 4px; }
|
|
||||||
.sheet-label { font-size: 0.75rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: #6b7280; }
|
|
||||||
.sheet-text { font-size: 0.9rem; color: #374151; margin: 0; line-height: 1.5; }
|
|
||||||
.sheet-tags { display: flex; flex-wrap: wrap; gap: 6px; }
|
|
||||||
.sheet-tag { font-size: 0.75rem; background: #f3f4f6; color: #374151; padding: 2px 8px; border-radius: 12px; }
|
|
||||||
.sheet-edit-btn { display: block; text-align: center; background: #1B4436; color: white; border-radius: 8px; padding: 12px; text-decoration: none; font-weight: 600; }
|
|
||||||
.sheet-close { background: transparent; border: 1px solid #d1d5db; border-radius: 8px; padding: 10px; color: #6b7280; cursor: pointer; font-size: 0.875rem; }
|
|
||||||
.sheet-enter-active, .sheet-leave-active { transition: opacity 0.2s; }
|
|
||||||
.sheet-enter-from, .sheet-leave-to { opacity: 0; }
|
|
||||||
|
|
||||||
/* ── QR link ── */
|
|
||||||
|
|
||||||
.qr-link {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: #9ca3af;
|
|
||||||
text-decoration: none;
|
|
||||||
align-self: flex-end;
|
|
||||||
}
|
|
||||||
.qr-link:hover { color: #6b7280; }
|
|
||||||
|
|
||||||
/* ── Annuaire ── */
|
|
||||||
|
|
||||||
.annuaire-wrap {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.annuaire-scroll {
|
|
||||||
overflow-x: auto;
|
|
||||||
-webkit-overflow-scrolling: touch;
|
|
||||||
border: 1px solid #e5e7eb;
|
|
||||||
border-radius: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.annuaire-table {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
min-width: 480px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.annuaire-table thead tr {
|
|
||||||
background: #f9fafb;
|
|
||||||
border-bottom: 2px solid #e5e7eb;
|
|
||||||
}
|
|
||||||
|
|
||||||
.annuaire-table th {
|
|
||||||
padding: 10px 14px;
|
|
||||||
text-align: left;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-weight: 700;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
color: #6b7280;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.annuaire-table td {
|
|
||||||
padding: 12px 14px;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
color: #374151;
|
|
||||||
vertical-align: top;
|
|
||||||
border-bottom: 1px solid #f3f4f6;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.annuaire-row {
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 0.12s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.annuaire-row:hover { background: #f9fafb; }
|
|
||||||
.annuaire-row:last-child td { border-bottom: none; }
|
|
||||||
|
|
||||||
.col-nom {
|
|
||||||
position: sticky;
|
|
||||||
left: 0;
|
|
||||||
z-index: 2;
|
|
||||||
background: #ffffff;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #1a1a2e !important;
|
|
||||||
white-space: nowrap;
|
|
||||||
min-width: 80px;
|
|
||||||
border-right: 2px solid #e5e7eb;
|
|
||||||
box-shadow: 2px 0 6px rgba(0,0,0,0.06);
|
|
||||||
}
|
|
||||||
|
|
||||||
.annuaire-row:hover .col-nom { background: #f9fafb; }
|
|
||||||
thead tr .col-nom { background: #f9fafb; z-index: 3; }
|
|
||||||
|
|
||||||
.col-besoin { min-width: 200px; max-width: 260px; }
|
|
||||||
.col-offre { min-width: 200px; max-width: 260px; }
|
|
||||||
|
|
||||||
.annuaire-hint {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: #9ca3af;
|
|
||||||
text-align: center;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.col-actions { width: 40px; text-align: center; }
|
|
||||||
.delete-btn {
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
color: #ef4444;
|
|
||||||
font-size: 1rem;
|
|
||||||
padding: 4px 8px;
|
|
||||||
border-radius: 4px;
|
|
||||||
transition: background 0.1s;
|
|
||||||
}
|
|
||||||
.delete-btn:hover { background: #fef2f2; }
|
|
||||||
|
|
||||||
/* ── Mobile ── */
|
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
|
||||||
.codev-carto {
|
|
||||||
padding: 1rem 0.75rem 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.carto-header h1 {
|
|
||||||
font-size: 1.25rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,406 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="codev-demo">
|
|
||||||
|
|
||||||
<header class="demo-header">
|
|
||||||
<span class="demo-badge">DEMO</span>
|
|
||||||
<h1>Co-developpement - exemple</h1>
|
|
||||||
<p class="subtitle">10 personnes fictives. Clique sur un mode pour voir les matchs.</p>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div class="codev-tabs">
|
|
||||||
<button :class="{ active: tab === 'carto' }" @click="tab = 'carto'" type="button">Carto</button>
|
|
||||||
<button :class="{ active: tab === 'annuaire' }" @click="tab = 'annuaire'" type="button">Annuaire</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="tab === 'carto'">
|
|
||||||
<ClientOnly>
|
|
||||||
<CodevGraph
|
|
||||||
:fiches="fiches"
|
|
||||||
:matches="matches"
|
|
||||||
:mode="mode"
|
|
||||||
/>
|
|
||||||
<template #fallback>
|
|
||||||
<div class="graph-fallback">Chargement du graphe...</div>
|
|
||||||
</template>
|
|
||||||
</ClientOnly>
|
|
||||||
|
|
||||||
<!-- Bandeau info mode actif -->
|
|
||||||
<div v-if="mode !== 'none'" class="mode-banner">
|
|
||||||
<span>
|
|
||||||
Mode {{ MODE_LABELS[mode] }} actif -
|
|
||||||
{{ matches.length }} connexion{{ matches.length !== 1 ? 's' : '' }} trouvee{{ matches.length !== 1 ? 's' : '' }}.
|
|
||||||
</span>
|
|
||||||
<button class="banner-clear" @click="setMode('none')" type="button">Effacer</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Boutons matching -->
|
|
||||||
<div class="matching-controls">
|
|
||||||
<button
|
|
||||||
:class="{ active: mode === 'solution' }"
|
|
||||||
style="--mode-color: #22c55e"
|
|
||||||
@click="setMode('solution')"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
Solution
|
|
||||||
<span class="hint">besoin - offre</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
:class="{ active: mode === 'alliance' }"
|
|
||||||
style="--mode-color: #f97316"
|
|
||||||
@click="setMode('alliance')"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
Alliance
|
|
||||||
<span class="hint">besoins partages</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
v-if="mode !== 'none'"
|
|
||||||
class="reset"
|
|
||||||
@click="setMode('none')"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
Effacer
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else-if="tab === 'annuaire'" class="annuaire-wrap">
|
|
||||||
<div class="annuaire-scroll">
|
|
||||||
<table class="annuaire-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th class="col-nom">Prénom</th>
|
|
||||||
<th class="col-besoin">Besoin</th>
|
|
||||||
<th class="col-offre">Ce que j'offre</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr v-for="f in fiches" :key="f.id" class="annuaire-row">
|
|
||||||
<td class="col-nom">{{ f.nom }}</td>
|
|
||||||
<td class="col-besoin">{{ f.besoin }}</td>
|
|
||||||
<td class="col-offre">{{ f.offre }}</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import type { CodevFiche, CodevMatch } from '~/types/codev'
|
|
||||||
import { computeMatches } from '~/utils/codev/matching'
|
|
||||||
|
|
||||||
const tab = ref<'carto' | 'annuaire'>('carto')
|
|
||||||
|
|
||||||
// 10 fiches sans hashtags — textes enrichis pour que scoreDirect discrimine bien les 3 modes :
|
|
||||||
//
|
|
||||||
// Solution (scoreDirect besoinA vs offreB) :
|
|
||||||
// Sami(besoin vendre formation) -> Ines(offre vente formations) ✓
|
|
||||||
// Nael(besoin site web formation) -> Sami(offre developpement web) ✓
|
|
||||||
// Eva(besoin coaching vente) -> Ines(offre vente formations) ✓
|
|
||||||
// Tom(besoin tiers-lieu) -> Zoe(offre facilitation tiers-lieux) ✓
|
|
||||||
//
|
|
||||||
// Alliance (besoins similaires) :
|
|
||||||
// Lea + Maya (coaching, lancer, offre) ✓
|
|
||||||
// Tom + Zoe (tiers-lieu, co-creer) ✓
|
|
||||||
// Sami + Kenji (vendre, formations) ✓
|
|
||||||
//
|
|
||||||
// Surprise (offres similaires) :
|
|
||||||
// Lea + Zoe (facilitation, groupes) ✓
|
|
||||||
// Tom + Roman (architecture) ✓
|
|
||||||
// Ines + Nael (marketing, formations) ✓
|
|
||||||
|
|
||||||
const FICHES_DEMO: CodevFiche[] = [
|
|
||||||
{
|
|
||||||
id: 1, nom: 'Lea',
|
|
||||||
besoin: 'Structurer et lancer mon offre de coaching professionnel cet automne',
|
|
||||||
offre: 'Facilitation de groupes et animation de cercles de parole',
|
|
||||||
hashtags: [],
|
|
||||||
created_at: '2026-05-08T10:00:00Z',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 2, nom: 'Sami',
|
|
||||||
besoin: 'Vendre ma formation en ligne et attirer mes premiers clients',
|
|
||||||
offre: 'Developpement web sur mesure, creation de sites et applications',
|
|
||||||
hashtags: [],
|
|
||||||
created_at: '2026-05-08T10:01:00Z',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 3, nom: 'Ines',
|
|
||||||
besoin: 'Ameliorer la facilitation de mes ateliers collaboratifs',
|
|
||||||
offre: 'Vente de formations en ligne et marketing pour formateurs',
|
|
||||||
hashtags: [],
|
|
||||||
created_at: '2026-05-08T10:02:00Z',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 4, nom: 'Tom',
|
|
||||||
besoin: 'Trouver des associes pour co-creer un tiers-lieu rural',
|
|
||||||
offre: 'Architecture bioclimatique et eco-construction pour tiers-lieux',
|
|
||||||
hashtags: [],
|
|
||||||
created_at: '2026-05-08T10:03:00Z',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 5, nom: 'Maya',
|
|
||||||
besoin: 'Creer et lancer mon offre de coaching en transition professionnelle',
|
|
||||||
offre: 'Accompagnement coaching de carriere et transitions professionnelles',
|
|
||||||
hashtags: [],
|
|
||||||
created_at: '2026-05-08T10:04:00Z',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 6, nom: 'Kenji',
|
|
||||||
besoin: 'Apprendre a vendre mes formations sans pression commerciale',
|
|
||||||
offre: 'Photographie professionnelle et direction artistique editoriale',
|
|
||||||
hashtags: [],
|
|
||||||
created_at: '2026-05-08T10:05:00Z',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 7, nom: 'Zoe',
|
|
||||||
besoin: 'Co-creer un tiers-lieu avec des porteurs de projet alignes',
|
|
||||||
offre: 'Facilitation de collectifs et animation en intelligence collective',
|
|
||||||
hashtags: [],
|
|
||||||
created_at: '2026-05-08T10:06:00Z',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 8, nom: 'Nael',
|
|
||||||
besoin: 'Creer un site web pour presenter et vendre ma formation',
|
|
||||||
offre: 'Strategie marketing digital et lancement de produits en ligne',
|
|
||||||
hashtags: [],
|
|
||||||
created_at: '2026-05-08T10:07:00Z',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 9, nom: 'Eva',
|
|
||||||
besoin: 'Lancer mon coaching avec une page de vente qui convertit',
|
|
||||||
offre: 'Ecriture longue forme, articles de fond et tribunes editoriales',
|
|
||||||
hashtags: [],
|
|
||||||
created_at: '2026-05-08T10:08:00Z',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 10, nom: 'Roman',
|
|
||||||
besoin: 'Ecrire de meilleurs articles pour mon blog et ma newsletter',
|
|
||||||
offre: 'Architecture technique et plans pour renovation energetique',
|
|
||||||
hashtags: [],
|
|
||||||
created_at: '2026-05-08T10:09:00Z',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
const fiches = ref(FICHES_DEMO)
|
|
||||||
const matches = ref<CodevMatch[]>([])
|
|
||||||
const mode = ref<'none' | 'solution' | 'alliance' | 'surprise'>('none')
|
|
||||||
|
|
||||||
const MODE_LABELS: Record<string, string> = {
|
|
||||||
solution: 'Solution',
|
|
||||||
alliance: 'Alliance',
|
|
||||||
surprise: 'Surprise',
|
|
||||||
}
|
|
||||||
|
|
||||||
useHead({ title: 'Demo - Co-developpement' })
|
|
||||||
|
|
||||||
function setMode(newMode: typeof mode.value) {
|
|
||||||
mode.value = newMode
|
|
||||||
if (newMode === 'none') {
|
|
||||||
matches.value = []
|
|
||||||
} else {
|
|
||||||
matches.value = computeMatches(fiches.value, newMode, 0.12)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.codev-demo {
|
|
||||||
min-height: 100vh;
|
|
||||||
background: var(--nav-bg, #fafafa);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
padding: 1.25rem 1rem 2rem;
|
|
||||||
gap: 1rem;
|
|
||||||
max-width: 100%;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── En-tete ── */
|
|
||||||
|
|
||||||
.demo-header {
|
|
||||||
text-align: center;
|
|
||||||
padding-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.demo-badge {
|
|
||||||
display: inline-block;
|
|
||||||
background: #f97316;
|
|
||||||
color: #fff;
|
|
||||||
font-size: 10px;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.08em;
|
|
||||||
padding: 4px 8px;
|
|
||||||
border-radius: 4px;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.demo-header h1 {
|
|
||||||
font-size: 1.5rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--nav-text, #1a1a2e);
|
|
||||||
margin: 0 0 0.375rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.subtitle {
|
|
||||||
font-size: 0.9rem;
|
|
||||||
color: var(--nav-text-muted, #6b7280);
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Fallback ── */
|
|
||||||
|
|
||||||
.graph-fallback {
|
|
||||||
width: 100%;
|
|
||||||
height: 70vh;
|
|
||||||
min-height: 320px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
color: var(--nav-text-muted, #6b7280);
|
|
||||||
font-size: 0.9rem;
|
|
||||||
background: var(--nav-bg-alt, #f3f4f6);
|
|
||||||
border-radius: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Tabs ── */
|
|
||||||
|
|
||||||
.codev-tabs { display: flex; gap: 4px; background: #f3f4f6; border-radius: 10px; padding: 4px; }
|
|
||||||
.codev-tabs button { flex: 1; padding: 8px 4px; border: none; border-radius: 7px; background: transparent; font-size: 0.875rem; font-weight: 500; cursor: pointer; color: #6b7280; transition: all 0.15s; }
|
|
||||||
.codev-tabs button.active { background: white; color: #1a1a2e; font-weight: 600; box-shadow: 0 1px 3px rgba(0,0,0,0.08); }
|
|
||||||
|
|
||||||
/* ── Annuaire ── */
|
|
||||||
|
|
||||||
.annuaire-wrap { display: flex; flex-direction: column; gap: 8px; flex: 1; }
|
|
||||||
.annuaire-scroll { overflow-x: auto; -webkit-overflow-scrolling: touch; border: 1px solid #e5e7eb; border-radius: 10px; }
|
|
||||||
.annuaire-table { width: 100%; border-collapse: collapse; min-width: 480px; }
|
|
||||||
.annuaire-table thead tr { background: #f9fafb; border-bottom: 2px solid #e5e7eb; }
|
|
||||||
.annuaire-table th { padding: 10px 14px; text-align: left; font-size: 0.75rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; color: #6b7280; white-space: nowrap; }
|
|
||||||
.annuaire-table td { padding: 12px 14px; font-size: 0.875rem; color: #374151; vertical-align: top; border-bottom: 1px solid #f3f4f6; line-height: 1.5; }
|
|
||||||
.annuaire-row { transition: background 0.12s; }
|
|
||||||
.annuaire-row:hover { background: #f9fafb; }
|
|
||||||
.annuaire-row:last-child td { border-bottom: none; }
|
|
||||||
.col-nom { position: sticky; left: 0; z-index: 2; background: #ffffff; font-weight: 600; color: #1a1a2e !important; white-space: nowrap; min-width: 80px; border-right: 2px solid #e5e7eb; box-shadow: 2px 0 6px rgba(0,0,0,0.06); }
|
|
||||||
.annuaire-row:hover .col-nom { background: #f9fafb; }
|
|
||||||
thead tr .col-nom { background: #f9fafb; z-index: 3; }
|
|
||||||
.col-besoin { min-width: 200px; max-width: 260px; }
|
|
||||||
.col-offre { min-width: 200px; max-width: 260px; }
|
|
||||||
|
|
||||||
/* ── Bandeau mode actif ── */
|
|
||||||
|
|
||||||
.mode-banner {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 0.75rem;
|
|
||||||
padding: 0.5rem 0.875rem;
|
|
||||||
background: #f0fdf4;
|
|
||||||
border: 1px solid #bbf7d0;
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
color: #166534;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.banner-clear {
|
|
||||||
font-size: 0.8rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #166534;
|
|
||||||
background: transparent;
|
|
||||||
border: 1px solid #166534;
|
|
||||||
border-radius: 6px;
|
|
||||||
padding: 0.2rem 0.6rem;
|
|
||||||
cursor: pointer;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.banner-clear:hover {
|
|
||||||
background: #166534;
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Boutons matching ── */
|
|
||||||
|
|
||||||
.matching-controls {
|
|
||||||
position: sticky;
|
|
||||||
bottom: 0;
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 12px;
|
|
||||||
background: rgba(255, 255, 255, 0.95);
|
|
||||||
backdrop-filter: blur(8px);
|
|
||||||
border-top: 1px solid #e5e7eb;
|
|
||||||
margin: 0 -1rem -2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.matching-controls button {
|
|
||||||
flex: 1;
|
|
||||||
padding: 12px 8px;
|
|
||||||
border: 1px solid #d0d4dc;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: white;
|
|
||||||
font-size: 14px;
|
|
||||||
cursor: pointer;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: 2px;
|
|
||||||
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.matching-controls button .hint {
|
|
||||||
font-size: 11px;
|
|
||||||
color: #6b7280;
|
|
||||||
font-weight: normal;
|
|
||||||
}
|
|
||||||
|
|
||||||
.matching-controls button.active {
|
|
||||||
background: var(--mode-color, #1B4436);
|
|
||||||
color: white;
|
|
||||||
border-color: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.matching-controls button.active .hint {
|
|
||||||
color: rgba(255, 255, 255, 0.8);
|
|
||||||
}
|
|
||||||
|
|
||||||
.matching-controls button.reset {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
padding: 12px 16px;
|
|
||||||
background: #f3f4f6;
|
|
||||||
border-color: #d0d4dc;
|
|
||||||
color: #374151;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.matching-controls button.reset:hover {
|
|
||||||
background: #e5e7eb;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 500px) {
|
|
||||||
.matching-controls {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(2, 1fr);
|
|
||||||
margin: 0 -0.75rem -1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.matching-controls button.reset {
|
|
||||||
grid-column: span 2;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Mobile ── */
|
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
|
||||||
.codev-demo {
|
|
||||||
padding: 1rem 0.75rem 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.demo-header h1 {
|
|
||||||
font-size: 1.25rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,415 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="fiche-page">
|
|
||||||
<div class="fiche-inner">
|
|
||||||
|
|
||||||
<!-- En-tête -->
|
|
||||||
<div class="fiche-header">
|
|
||||||
<NuxtLink to="/codev/carto" class="back-link">← Retour à la carte</NuxtLink>
|
|
||||||
<h1>{{ isEdit ? 'Modifier ma fiche' : 'Ma fiche' }}</h1>
|
|
||||||
<p class="fiche-lead">3 lignes pour te présenter. Le reste se passe entre nous.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Formulaire -->
|
|
||||||
<form class="fiche-form" @submit.prevent="submit" novalidate>
|
|
||||||
|
|
||||||
<!-- Nom -->
|
|
||||||
<div class="field-group">
|
|
||||||
<label for="nom">
|
|
||||||
Prénom <span class="required">*</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="nom"
|
|
||||||
v-model="form.nom"
|
|
||||||
type="text"
|
|
||||||
placeholder="Ex : Camille"
|
|
||||||
required
|
|
||||||
minlength="2"
|
|
||||||
maxlength="50"
|
|
||||||
:disabled="loading"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Besoin -->
|
|
||||||
<div class="field-group">
|
|
||||||
<div class="label-row">
|
|
||||||
<label for="besoin">
|
|
||||||
Mon besoin actuel <span class="required">*</span>
|
|
||||||
</label>
|
|
||||||
<button type="button" class="tooltip-trigger" @click="toggleTip('besoin')" aria-label="C'est quoi un besoin ?">?</button>
|
|
||||||
</div>
|
|
||||||
<details v-if="activeTip === 'besoin'" class="tooltip-block" open>
|
|
||||||
<summary class="sr-only">Aide</summary>
|
|
||||||
<p>Un besoin, c'est ce qui te manque pour avancer. Ca peut etre concret (un coup de main sur un dossier) ou plus large (clarifier ou tu vas). Pas grave si c'est flou - la rencontre IRL aide a le preciser.</p>
|
|
||||||
</details>
|
|
||||||
<textarea
|
|
||||||
id="besoin"
|
|
||||||
v-model="form.besoin"
|
|
||||||
rows="3"
|
|
||||||
placeholder="Ex : J'ai besoin d'aide pour structurer mon offre de prestation"
|
|
||||||
required
|
|
||||||
minlength="5"
|
|
||||||
maxlength="300"
|
|
||||||
:disabled="loading"
|
|
||||||
/>
|
|
||||||
<span class="char-count" :class="{ 'char-warn': form.besoin.length > 260 }">
|
|
||||||
{{ form.besoin.length }}/300
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Offre -->
|
|
||||||
<div class="field-group">
|
|
||||||
<div class="label-row">
|
|
||||||
<label for="offre">
|
|
||||||
Ce que j'offre a la communaute <span class="required">*</span>
|
|
||||||
</label>
|
|
||||||
<button type="button" class="tooltip-trigger" @click="toggleTip('offre')" aria-label="C'est quoi une offre ?">?</button>
|
|
||||||
</div>
|
|
||||||
<details v-if="activeTip === 'offre'" class="tooltip-block" open>
|
|
||||||
<summary class="sr-only">Aide</summary>
|
|
||||||
<p>Une offre, c'est une competence, une experience ou une qualite que tu peux partager. Ce que les autres viennent chercher chez toi naturellement.</p>
|
|
||||||
</details>
|
|
||||||
<textarea
|
|
||||||
id="offre"
|
|
||||||
v-model="form.offre"
|
|
||||||
rows="3"
|
|
||||||
placeholder="Ex : Je peux partager mon expérience en facilitation de groupe"
|
|
||||||
required
|
|
||||||
minlength="5"
|
|
||||||
maxlength="300"
|
|
||||||
:disabled="loading"
|
|
||||||
/>
|
|
||||||
<span class="char-count" :class="{ 'char-warn': form.offre.length > 260 }">
|
|
||||||
{{ form.offre.length }}/300
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Hashtags -->
|
|
||||||
<div class="field-group">
|
|
||||||
<label for="hashtags">
|
|
||||||
Mots-clés
|
|
||||||
<span class="label-hint">(optionnel, 3 max, séparés par des virgules)</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="hashtags"
|
|
||||||
v-model="form.hashtagsRaw"
|
|
||||||
type="text"
|
|
||||||
placeholder="Ex : business, écriture, écologie"
|
|
||||||
maxlength="120"
|
|
||||||
:disabled="loading"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Erreur serveur -->
|
|
||||||
<div v-if="error" class="server-error" role="alert">
|
|
||||||
{{ error }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Bouton -->
|
|
||||||
<button type="submit" class="submit-btn" :disabled="loading">
|
|
||||||
{{ isEdit ? (loading ? 'Modification...' : 'Enregistrer les modifications') : (loading ? 'Envoi en cours...' : 'Ajouter ma fiche') }}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<NuxtLink to="/codev/carto" class="skip-link">
|
|
||||||
Voir la carte sans créer de fiche →
|
|
||||||
</NuxtLink>
|
|
||||||
|
|
||||||
</form>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
const route = useRoute()
|
|
||||||
const editId = computed(() => route.query.id ? Number(route.query.id) : null)
|
|
||||||
const isEdit = computed(() => editId.value !== null)
|
|
||||||
|
|
||||||
const form = ref({ nom: '', besoin: '', offre: '', hashtagsRaw: '' })
|
|
||||||
const error = ref('')
|
|
||||||
const loading = ref(false)
|
|
||||||
const activeTip = ref<'besoin' | 'offre' | null>(null)
|
|
||||||
|
|
||||||
useHead({ title: computed(() => isEdit.value ? 'Modifier ma fiche — Co-développement' : 'Ma fiche — Co-développement') })
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
if (!isEdit.value) return
|
|
||||||
try {
|
|
||||||
const fiche = await $fetch<any>(`/api/codev/fiches/${editId.value}`)
|
|
||||||
form.value.nom = fiche.nom
|
|
||||||
form.value.besoin = fiche.besoin
|
|
||||||
form.value.offre = fiche.offre
|
|
||||||
form.value.hashtagsRaw = fiche.hashtags.join(', ')
|
|
||||||
} catch {
|
|
||||||
error.value = 'Impossible de charger la fiche, elle a peut-etre ete supprimee.'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
function toggleTip(field: 'besoin' | 'offre') {
|
|
||||||
activeTip.value = activeTip.value === field ? null : field
|
|
||||||
}
|
|
||||||
|
|
||||||
async function submit() {
|
|
||||||
error.value = ''
|
|
||||||
loading.value = true
|
|
||||||
try {
|
|
||||||
const hashtags = form.value.hashtagsRaw
|
|
||||||
.split(',')
|
|
||||||
.map((h) => h.trim().toLowerCase().replace(/^#/, ''))
|
|
||||||
.filter(Boolean)
|
|
||||||
.slice(0, 3)
|
|
||||||
|
|
||||||
const payload = {
|
|
||||||
nom: form.value.nom,
|
|
||||||
besoin: form.value.besoin,
|
|
||||||
offre: form.value.offre,
|
|
||||||
hashtags,
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isEdit.value) {
|
|
||||||
await $fetch(`/api/codev/fiches/${editId.value}`, { method: 'PATCH', body: payload })
|
|
||||||
} else {
|
|
||||||
await $fetch('/api/codev/fiches', { method: 'POST', body: payload })
|
|
||||||
}
|
|
||||||
await navigateTo('/codev/carto')
|
|
||||||
} catch (e: any) {
|
|
||||||
error.value = e?.data?.message || e?.statusMessage || 'Erreur, reessaie'
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
/* ── Layout ── */
|
|
||||||
|
|
||||||
.fiche-page {
|
|
||||||
min-height: 100vh;
|
|
||||||
background: var(--nav-bg, #fafafa);
|
|
||||||
padding: 1.5rem 1rem 4rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fiche-inner {
|
|
||||||
max-width: 480px;
|
|
||||||
margin: 0 auto;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 1.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── En-tête ── */
|
|
||||||
|
|
||||||
.back-link {
|
|
||||||
display: inline-block;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
color: var(--nav-text-muted, #6b7280);
|
|
||||||
text-decoration: none;
|
|
||||||
margin-bottom: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.back-link:hover {
|
|
||||||
color: var(--nav-primary-solid, #1B4436);
|
|
||||||
}
|
|
||||||
|
|
||||||
.fiche-header h1 {
|
|
||||||
font-size: 1.5rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--nav-text, #1a1a2e);
|
|
||||||
margin: 0 0 0.375rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fiche-lead {
|
|
||||||
font-size: 0.9rem;
|
|
||||||
color: var(--nav-text-muted, #6b7280);
|
|
||||||
margin: 0;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Formulaire ── */
|
|
||||||
|
|
||||||
.fiche-form {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 1.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Champ ── */
|
|
||||||
|
|
||||||
.field-group {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.375rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.field-group label {
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--nav-text, #1a1a2e);
|
|
||||||
}
|
|
||||||
|
|
||||||
.label-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.label-hint {
|
|
||||||
font-weight: 400;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
color: var(--nav-text-muted, #6b7280);
|
|
||||||
margin-left: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.required {
|
|
||||||
color: #c0392b;
|
|
||||||
}
|
|
||||||
|
|
||||||
.field-group input[type="text"],
|
|
||||||
.field-group input[type="password"],
|
|
||||||
.field-group textarea {
|
|
||||||
width: 100%;
|
|
||||||
padding: 0.75rem 0.875rem;
|
|
||||||
border: 1px solid var(--border-color, #d0d4dc);
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 1rem;
|
|
||||||
color: var(--nav-text, #1a1a2e);
|
|
||||||
background: var(--nav-surface, #ffffff);
|
|
||||||
font-family: inherit;
|
|
||||||
transition: border-color 0.15s, box-shadow 0.15s;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
.field-group input:focus,
|
|
||||||
.field-group textarea:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: var(--nav-primary-solid, #1B4436);
|
|
||||||
box-shadow: 0 0 0 2px rgba(27, 68, 54, 0.15);
|
|
||||||
}
|
|
||||||
|
|
||||||
.field-group input:disabled,
|
|
||||||
.field-group textarea:disabled {
|
|
||||||
opacity: 0.6;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.field-group textarea {
|
|
||||||
resize: vertical;
|
|
||||||
min-height: 80px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.char-count {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: var(--nav-text-muted, #6b7280);
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
|
|
||||||
.char-warn {
|
|
||||||
color: #e67e22;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Tooltip ── */
|
|
||||||
|
|
||||||
.tooltip-trigger {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
background: var(--nav-surface, #ffffff);
|
|
||||||
border: 1px solid var(--border-color, #d0d4dc);
|
|
||||||
border-radius: 50%;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--nav-text-muted, #6b7280);
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 0;
|
|
||||||
line-height: 1;
|
|
||||||
flex-shrink: 0;
|
|
||||||
transition: border-color 0.15s, color 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tooltip-trigger:hover {
|
|
||||||
border-color: var(--nav-primary-solid, #1B4436);
|
|
||||||
color: var(--nav-primary-solid, #1B4436);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tooltip-block {
|
|
||||||
background: var(--nav-surface, #ffffff);
|
|
||||||
border: 1px solid var(--border-color, #d0d4dc);
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 0.75rem 0.875rem;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
color: var(--nav-text-muted, #6b7280);
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tooltip-block p {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sr-only {
|
|
||||||
position: absolute;
|
|
||||||
width: 1px;
|
|
||||||
height: 1px;
|
|
||||||
padding: 0;
|
|
||||||
margin: -1px;
|
|
||||||
overflow: hidden;
|
|
||||||
clip: rect(0, 0, 0, 0);
|
|
||||||
border: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Erreur serveur ── */
|
|
||||||
|
|
||||||
.server-error {
|
|
||||||
padding: 0.75rem 0.875rem;
|
|
||||||
background: #fdf0ee;
|
|
||||||
border: 1px solid #e74c3c;
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
color: #c0392b;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Bouton ── */
|
|
||||||
|
|
||||||
.submit-btn {
|
|
||||||
width: 100%;
|
|
||||||
padding: 0.875rem 1rem;
|
|
||||||
background: var(--nav-primary-solid, #1B4436);
|
|
||||||
color: #ffffff;
|
|
||||||
border: none;
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 1rem;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
font-family: inherit;
|
|
||||||
transition: opacity 0.15s;
|
|
||||||
margin-top: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.submit-btn:hover:not(:disabled) {
|
|
||||||
opacity: 0.88;
|
|
||||||
}
|
|
||||||
|
|
||||||
.submit-btn:disabled {
|
|
||||||
opacity: 0.45;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.skip-link {
|
|
||||||
display: block;
|
|
||||||
text-align: center;
|
|
||||||
font-size: 0.825rem;
|
|
||||||
color: var(--nav-text-muted, #9ca3af);
|
|
||||||
text-decoration: none;
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
padding: 0.5rem;
|
|
||||||
}
|
|
||||||
.skip-link:hover { color: var(--nav-text, #1a1a2e); }
|
|
||||||
|
|
||||||
/* ── Responsive ── */
|
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
|
||||||
.fiche-page {
|
|
||||||
padding: 1.25rem 1rem 3rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,225 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="lock-page">
|
|
||||||
<div class="lock-inner">
|
|
||||||
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<form class="lock-form" @submit.prevent="submit" novalidate>
|
|
||||||
|
|
||||||
<div class="field-group">
|
|
||||||
<input
|
|
||||||
id="password"
|
|
||||||
v-model="password"
|
|
||||||
type="password"
|
|
||||||
placeholder="Mot de passe"
|
|
||||||
autocomplete="current-password"
|
|
||||||
required
|
|
||||||
:disabled="loading"
|
|
||||||
class="lock-input"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="error" class="lock-error" role="alert">
|
|
||||||
{{ error }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button type="submit" class="lock-btn" :disabled="loading || !password">
|
|
||||||
{{ loading ? 'Vérification...' : 'Entrer' }}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<div class="lock-footer">
|
|
||||||
<NuxtLink to="/codev/demo" class="demo-link">Voir l'exemple →</NuxtLink>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
const password = ref('')
|
|
||||||
const error = ref('')
|
|
||||||
const loading = ref(false)
|
|
||||||
|
|
||||||
useHead({ title: 'Co-développement — Entraide entre pairs' })
|
|
||||||
|
|
||||||
async function submit() {
|
|
||||||
error.value = ''
|
|
||||||
loading.value = true
|
|
||||||
try {
|
|
||||||
await $fetch('/api/codev/auth', {
|
|
||||||
method: 'POST',
|
|
||||||
body: { password: password.value },
|
|
||||||
})
|
|
||||||
await navigateTo('/codev/fiche')
|
|
||||||
} catch (e: any) {
|
|
||||||
error.value = e?.statusMessage || 'Mauvais mot de passe'
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
/* ── Layout ── */
|
|
||||||
|
|
||||||
.lock-page {
|
|
||||||
min-height: 100vh;
|
|
||||||
background: var(--nav-bg, #fafafa);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 1.5rem 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.lock-inner {
|
|
||||||
width: 100%;
|
|
||||||
max-width: 400px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── En-tête ── */
|
|
||||||
|
|
||||||
.lock-header {
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.lock-header h1 {
|
|
||||||
font-size: 1.75rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--nav-text, #1a1a2e);
|
|
||||||
margin: 0 0 0.375rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.lock-subtitle {
|
|
||||||
font-size: 1rem;
|
|
||||||
color: var(--nav-text-muted, #6b7280);
|
|
||||||
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);
|
|
||||||
line-height: 1.5;
|
|
||||||
margin: 0;
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Formulaire ── */
|
|
||||||
|
|
||||||
.lock-form {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.875rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.field-group {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.lock-input {
|
|
||||||
width: 100%;
|
|
||||||
padding: 0.875rem 1rem;
|
|
||||||
border: 1px solid var(--border-color, #d0d4dc);
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 1rem;
|
|
||||||
color: var(--nav-text, #1a1a2e);
|
|
||||||
background: var(--nav-surface, #ffffff);
|
|
||||||
font-family: inherit;
|
|
||||||
text-align: center;
|
|
||||||
letter-spacing: 0.1em;
|
|
||||||
transition: border-color 0.15s, box-shadow 0.15s;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
.lock-input:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: var(--nav-primary-solid, #1B4436);
|
|
||||||
box-shadow: 0 0 0 2px rgba(27, 68, 54, 0.15);
|
|
||||||
}
|
|
||||||
|
|
||||||
.lock-input:disabled {
|
|
||||||
opacity: 0.6;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Erreur ── */
|
|
||||||
|
|
||||||
.lock-error {
|
|
||||||
padding: 0.625rem 0.875rem;
|
|
||||||
background: #fdf0ee;
|
|
||||||
border: 1px solid #e74c3c;
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
color: #c0392b;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Bouton ── */
|
|
||||||
|
|
||||||
.lock-btn {
|
|
||||||
width: 100%;
|
|
||||||
padding: 0.875rem 1rem;
|
|
||||||
background: var(--nav-primary-solid, #1B4436);
|
|
||||||
color: #ffffff;
|
|
||||||
border: none;
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 1rem;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
font-family: inherit;
|
|
||||||
transition: opacity 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.lock-btn:hover:not(:disabled) {
|
|
||||||
opacity: 0.88;
|
|
||||||
}
|
|
||||||
|
|
||||||
.lock-btn:disabled {
|
|
||||||
opacity: 0.45;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Pied de page ── */
|
|
||||||
|
|
||||||
.lock-footer {
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.demo-link {
|
|
||||||
font-size: 0.875rem;
|
|
||||||
color: var(--nav-text-muted, #6b7280);
|
|
||||||
text-decoration: none;
|
|
||||||
transition: color 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.demo-link:hover {
|
|
||||||
color: var(--nav-primary-solid, #1B4436);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Responsive ── */
|
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
|
||||||
.lock-page {
|
|
||||||
padding: 1.25rem 1rem 2.5rem;
|
|
||||||
align-items: flex-start;
|
|
||||||
padding-top: 3rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="qr-page">
|
|
||||||
<div class="qr-card">
|
|
||||||
<h1>Co-développement</h1>
|
|
||||||
<p class="qr-subtitle">Scanne pour rejoindre la session</p>
|
|
||||||
|
|
||||||
<img
|
|
||||||
:src="`https://api.qrserver.com/v1/create-qr-code/?size=280x280&data=${encodeURIComponent(APP_URL)}&bgcolor=ffffff&color=1B4436&margin=2`"
|
|
||||||
alt="QR code aep.trans-former.fr/codev"
|
|
||||||
class="qr-img"
|
|
||||||
width="280"
|
|
||||||
height="280"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<p class="qr-url">{{ APP_URL }}</p>
|
|
||||||
<p class="qr-password">Mot de passe : <strong>merci</strong></p>
|
|
||||||
|
|
||||||
<a :href="`https://api.qrserver.com/v1/create-qr-code/?size=600x600&data=${encodeURIComponent(APP_URL)}&bgcolor=ffffff&color=1B4436&margin=2`"
|
|
||||||
download="codev-qr.png"
|
|
||||||
class="qr-download"
|
|
||||||
target="_blank"
|
|
||||||
>
|
|
||||||
Télécharger le QR code
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
const APP_URL = 'https://aep.trans-former.fr/codev'
|
|
||||||
useHead({ title: 'QR Code — Co-développement' })
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.qr-page {
|
|
||||||
min-height: 100vh;
|
|
||||||
background: var(--nav-bg, #fafafa);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 2rem 1rem;
|
|
||||||
}
|
|
||||||
.qr-card {
|
|
||||||
background: white;
|
|
||||||
border-radius: 16px;
|
|
||||||
padding: 2rem;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: 1rem;
|
|
||||||
max-width: 360px;
|
|
||||||
width: 100%;
|
|
||||||
box-shadow: 0 4px 24px rgba(0,0,0,0.08);
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
.qr-card h1 {
|
|
||||||
font-size: 1.25rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #1a1a2e;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
.qr-subtitle {
|
|
||||||
font-size: 0.9rem;
|
|
||||||
color: #6b7280;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
.qr-img {
|
|
||||||
border-radius: 8px;
|
|
||||||
border: 2px solid #e5e7eb;
|
|
||||||
}
|
|
||||||
.qr-url {
|
|
||||||
font-size: 0.8rem;
|
|
||||||
color: #9ca3af;
|
|
||||||
margin: 0;
|
|
||||||
font-family: monospace;
|
|
||||||
}
|
|
||||||
.qr-password {
|
|
||||||
font-size: 0.95rem;
|
|
||||||
color: #374151;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
.qr-download {
|
|
||||||
display: inline-block;
|
|
||||||
padding: 10px 20px;
|
|
||||||
background: #1B4436;
|
|
||||||
color: white;
|
|
||||||
border-radius: 8px;
|
|
||||||
text-decoration: none;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-weight: 600;
|
|
||||||
transition: opacity 0.15s;
|
|
||||||
}
|
|
||||||
.qr-download:hover { opacity: 0.88; }
|
|
||||||
</style>
|
|
||||||
+790
-2
@@ -1,7 +1,795 @@
|
|||||||
<template>
|
<template>
|
||||||
<div />
|
<div class="contribuer-page">
|
||||||
|
<div class="contribuer-inner">
|
||||||
|
<!-- Retour -->
|
||||||
|
<NuxtLink to="/" class="back-link">
|
||||||
|
← Retour à la carte
|
||||||
|
</NuxtLink>
|
||||||
|
|
||||||
|
<!-- En-tête -->
|
||||||
|
<div class="contribuer-header">
|
||||||
|
<h1>Proposer une ressource</h1>
|
||||||
|
<p class="contribuer-subtitle">
|
||||||
|
Tu connais une organisation utile aux architectes qui n'est pas encore référencée ?
|
||||||
|
Soumets-la ici — une IA enrichira la fiche et on validera sous 7 jours.
|
||||||
|
</p>
|
||||||
|
<p class="contribuer-hint">
|
||||||
|
Si tu n'as pas le temps de tout remplir, laisse-nous juste le lien — on extraira les infos du site.
|
||||||
|
Mais une description de toi, c'est toujours plus vivant et plus précis.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Message succès -->
|
||||||
|
<div v-if="success" class="success-block" role="status" aria-live="polite">
|
||||||
|
<div class="success-icon">✓</div>
|
||||||
|
<h2>Merci !</h2>
|
||||||
|
<p>Ta fiche est en cours de traitement.</p>
|
||||||
|
<p class="success-detail">
|
||||||
|
Une IA va scraper le site et enrichir la description.
|
||||||
|
Jules (et bientôt une équipe de modération) valide sous 7 jours.
|
||||||
|
</p>
|
||||||
|
<button type="button" class="btn-secondary" @click="reset">
|
||||||
|
Proposer une autre fiche
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Formulaire -->
|
||||||
|
<form v-else @submit.prevent="submit" class="contribuer-form" novalidate>
|
||||||
|
|
||||||
|
<!-- Nom -->
|
||||||
|
<div class="field-group" :class="{ 'field-error': errors.nom }">
|
||||||
|
<label for="nom">Nom de l'organisation <span class="required">*</span></label>
|
||||||
|
<input
|
||||||
|
id="nom"
|
||||||
|
v-model="form.nom"
|
||||||
|
type="text"
|
||||||
|
placeholder="Ex : UNSFA, Maison de l'Architecture..."
|
||||||
|
autocomplete="organization"
|
||||||
|
@blur="validateField('nom')"
|
||||||
|
/>
|
||||||
|
<span v-if="errors.nom" class="error-msg" role="alert">{{ errors.nom }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- URL -->
|
||||||
|
<div class="field-group" :class="{ 'field-error': errors.url }">
|
||||||
|
<label for="url">
|
||||||
|
Site web
|
||||||
|
<span class="label-hint">(optionnel — recommandé pour l'enrichissement IA)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="url"
|
||||||
|
v-model="form.url"
|
||||||
|
type="url"
|
||||||
|
placeholder="https://..."
|
||||||
|
@blur="validateField('url')"
|
||||||
|
/>
|
||||||
|
<span v-if="errors.url" class="error-msg" role="alert">{{ errors.url }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Description -->
|
||||||
|
<div class="field-group" :class="{ 'field-error': errors.description_user }">
|
||||||
|
<label for="description_user">
|
||||||
|
Description courte <span class="required">*</span>
|
||||||
|
<span class="label-hint">(50 à 500 caractères)</span>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="description_user"
|
||||||
|
v-model="form.description_user"
|
||||||
|
rows="4"
|
||||||
|
placeholder="Présente l'organisation en quelques mots : ses missions, son public, ce qu'elle apporte..."
|
||||||
|
@blur="validateField('description_user')"
|
||||||
|
/>
|
||||||
|
<div class="field-meta">
|
||||||
|
<span v-if="errors.description_user" class="error-msg" role="alert">
|
||||||
|
{{ errors.description_user }}
|
||||||
|
</span>
|
||||||
|
<span v-else class="char-count" :class="{ 'char-warn': form.description_user.length > 450 }">
|
||||||
|
{{ form.description_user.length }}/500
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Échelle -->
|
||||||
|
<div class="field-group" :class="{ 'field-error': errors.echelle }">
|
||||||
|
<fieldset>
|
||||||
|
<legend>
|
||||||
|
Échelle <span class="required">*</span>
|
||||||
|
<span class="label-hint">(une seule)</span>
|
||||||
|
</legend>
|
||||||
|
<div class="radio-group">
|
||||||
|
<label
|
||||||
|
v-for="opt in ECHELLES"
|
||||||
|
:key="opt"
|
||||||
|
class="radio-label"
|
||||||
|
:class="{ active: form.echelle === opt }"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
:value="opt"
|
||||||
|
v-model="form.echelle"
|
||||||
|
name="echelle"
|
||||||
|
@change="validateField('echelle')"
|
||||||
|
/>
|
||||||
|
{{ opt }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
<span v-if="errors.echelle" class="error-msg" role="alert">{{ errors.echelle }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Fonctions -->
|
||||||
|
<div class="field-group" :class="{ 'field-error': errors.fonctions }">
|
||||||
|
<fieldset>
|
||||||
|
<legend>
|
||||||
|
Fonctions <span class="required">*</span>
|
||||||
|
<span class="label-hint">(1 à 5 — l'ordre de clic = priorité)</span>
|
||||||
|
</legend>
|
||||||
|
<div class="checkbox-grid">
|
||||||
|
<label
|
||||||
|
v-for="fn in FONCTIONS"
|
||||||
|
:key="fn"
|
||||||
|
class="checkbox-label"
|
||||||
|
:class="{
|
||||||
|
active: form.fonctions.includes(fn),
|
||||||
|
disabled: !form.fonctions.includes(fn) && form.fonctions.length >= 5,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
:value="fn"
|
||||||
|
:checked="form.fonctions.includes(fn)"
|
||||||
|
:disabled="!form.fonctions.includes(fn) && form.fonctions.length >= 5"
|
||||||
|
@change="toggleFonction(fn)"
|
||||||
|
/>
|
||||||
|
<span class="fn-order" v-if="form.fonctions.includes(fn)">
|
||||||
|
{{ form.fonctions.indexOf(fn) + 1 }}
|
||||||
|
</span>
|
||||||
|
{{ fn }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
<span v-if="errors.fonctions" class="error-msg" role="alert">{{ errors.fonctions }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Territoire -->
|
||||||
|
<div class="field-group" :class="{ 'field-error': errors.territoire }">
|
||||||
|
<fieldset>
|
||||||
|
<legend>
|
||||||
|
Territoire <span class="required">*</span>
|
||||||
|
</legend>
|
||||||
|
<div class="radio-group">
|
||||||
|
<label
|
||||||
|
v-for="t in TERRITOIRES"
|
||||||
|
:key="t"
|
||||||
|
class="radio-label"
|
||||||
|
:class="{ active: form.territoire === t }"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
:value="t"
|
||||||
|
v-model="form.territoire"
|
||||||
|
name="territoire"
|
||||||
|
@change="validateField('territoire')"
|
||||||
|
/>
|
||||||
|
{{ t }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
<span v-if="errors.territoire" class="error-msg" role="alert">{{ errors.territoire }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Ville -->
|
||||||
|
<div class="field-group" :class="{ 'field-error': errors.localisation_ville }">
|
||||||
|
<label for="localisation_ville">
|
||||||
|
Ville principale
|
||||||
|
<span class="label-hint">(optionnel — pour la géolocalisation sur la carte)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="localisation_ville"
|
||||||
|
v-model="form.localisation_ville"
|
||||||
|
type="text"
|
||||||
|
placeholder="Ex : Paris, Lyon, Bordeaux..."
|
||||||
|
/>
|
||||||
|
<span v-if="errors.localisation_ville" class="error-msg" role="alert">
|
||||||
|
{{ errors.localisation_ville }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Email -->
|
||||||
|
<div class="field-group" :class="{ 'field-error': errors.submitted_by_email }">
|
||||||
|
<label for="submitted_by_email">
|
||||||
|
Ton email
|
||||||
|
<span class="label-hint">(optionnel — pour le suivi de modération)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="submitted_by_email"
|
||||||
|
v-model="form.submitted_by_email"
|
||||||
|
type="email"
|
||||||
|
placeholder="ton@email.fr"
|
||||||
|
autocomplete="email"
|
||||||
|
@blur="validateField('submitted_by_email')"
|
||||||
|
/>
|
||||||
|
<span v-if="errors.submitted_by_email" class="error-msg" role="alert">
|
||||||
|
{{ errors.submitted_by_email }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Erreur globale -->
|
||||||
|
<div v-if="serverError" class="server-error" role="alert">
|
||||||
|
<strong>Erreur :</strong> {{ serverError }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="form-actions">
|
||||||
|
<NuxtLink to="/" class="btn-secondary">Annuler</NuxtLink>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="btn-primary"
|
||||||
|
:disabled="submitting"
|
||||||
|
>
|
||||||
|
{{ submitting ? 'Envoi en cours...' : 'Proposer la fiche →' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="form-note">
|
||||||
|
Ta fiche sera examinée par l'équipe avant publication.
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
navigateTo('/proposer', { redirectCode: 301 })
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
// ── Constantes ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const ECHELLES = ['National', 'Régional', 'Local'] as const
|
||||||
|
const TERRITOIRES = ['Métropole', 'Guadeloupe', 'Martinique', 'Guyane', 'La Réunion', 'Mayotte'] as const
|
||||||
|
const FONCTIONS = [
|
||||||
|
'Juridique', 'Technique', 'Économique', 'Administratif', 'Chantier',
|
||||||
|
'Comptabilité', 'Développement', 'Formation', 'Gestion d\'agence', 'Santé mentale',
|
||||||
|
] as const
|
||||||
|
|
||||||
|
// ── Schéma Zod (côté client — miroir du serveur) ──────────────────────────────
|
||||||
|
|
||||||
|
const SubmitSchema = z.object({
|
||||||
|
nom: z.string().min(3, 'Minimum 3 caractères').max(150, 'Maximum 150 caractères').trim(),
|
||||||
|
url: z.string().url('URL invalide (commencer par https://)').optional().or(z.literal('')),
|
||||||
|
description_user: z.string().min(50, 'Minimum 50 caractères').max(500, 'Maximum 500 caractères').trim(),
|
||||||
|
echelle: z.enum(ECHELLES, { errorMap: () => ({ message: 'Sélectionne une échelle' }) }),
|
||||||
|
fonctions: z.array(z.string()).min(1, 'Sélectionne au moins une fonction').max(5, 'Maximum 5 fonctions'),
|
||||||
|
territoire: z.enum(TERRITOIRES, { errorMap: () => ({ message: 'Sélectionne un territoire' }) }),
|
||||||
|
localisation_ville: z.string().max(100).optional(),
|
||||||
|
submitted_by_email: z.string().email('Email invalide').optional().or(z.literal('')),
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── État du formulaire ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
nom: '',
|
||||||
|
url: '',
|
||||||
|
description_user: '',
|
||||||
|
echelle: '' as typeof ECHELLES[number] | '',
|
||||||
|
fonctions: [] as string[],
|
||||||
|
territoire: '' as typeof TERRITOIRES[number] | '',
|
||||||
|
localisation_ville: '',
|
||||||
|
submitted_by_email: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const errors = reactive<Record<string, string>>({})
|
||||||
|
const submitting = ref(false)
|
||||||
|
const success = ref(false)
|
||||||
|
const serverError = ref('')
|
||||||
|
const trackingUrl = ref<string | null>(null)
|
||||||
|
|
||||||
|
// ── Validation champ par champ ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function validateField(field: string) {
|
||||||
|
const partial = SubmitSchema.partial()
|
||||||
|
const result = partial.safeParse({ [field]: (form as any)[field] })
|
||||||
|
if (!result.success) {
|
||||||
|
const fieldErrors = result.error.flatten().fieldErrors
|
||||||
|
errors[field] = fieldErrors[field]?.[0] ?? ''
|
||||||
|
} else {
|
||||||
|
delete errors[field]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateAll(): boolean {
|
||||||
|
const result = SubmitSchema.safeParse(form)
|
||||||
|
if (!result.success) {
|
||||||
|
const flat = result.error.flatten().fieldErrors
|
||||||
|
Object.assign(errors, Object.fromEntries(
|
||||||
|
Object.entries(flat).map(([k, v]) => [k, v?.[0] ?? ''])
|
||||||
|
))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
Object.keys(errors).forEach(k => delete errors[k])
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Gestion fonctions (ordre de clic = priorité) ──────────────────────────────
|
||||||
|
|
||||||
|
function toggleFonction(fn: string) {
|
||||||
|
const idx = form.fonctions.indexOf(fn)
|
||||||
|
if (idx >= 0) {
|
||||||
|
form.fonctions.splice(idx, 1)
|
||||||
|
} else if (form.fonctions.length < 5) {
|
||||||
|
form.fonctions.push(fn)
|
||||||
|
}
|
||||||
|
validateField('fonctions')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Soumission ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
serverError.value = ''
|
||||||
|
|
||||||
|
if (!validateAll()) {
|
||||||
|
// Scroll vers la première erreur
|
||||||
|
await nextTick()
|
||||||
|
const firstError = document.querySelector('.field-error')
|
||||||
|
firstError?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
submitting.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result: any = await $fetch('/api/submit', {
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
nom: form.nom,
|
||||||
|
url: form.url || undefined,
|
||||||
|
description_user: form.description_user,
|
||||||
|
echelle: form.echelle,
|
||||||
|
fonctions: form.fonctions,
|
||||||
|
territoire: form.territoire,
|
||||||
|
localisation_ville: form.localisation_ville || undefined,
|
||||||
|
submitted_by_email: form.submitted_by_email || undefined,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
trackingUrl.value = result.trackingUrl ?? null
|
||||||
|
success.value = true
|
||||||
|
} catch (e: any) {
|
||||||
|
const status = e?.status ?? e?.statusCode
|
||||||
|
if (status === 429) {
|
||||||
|
serverError.value = 'Tu as déjà soumis 3 fiches aujourd\'hui. Réessaie demain.'
|
||||||
|
} else if (status === 422 && e?.data) {
|
||||||
|
// Erreurs Zod serveur → mapper sur le formulaire
|
||||||
|
const fieldErrors = e.data
|
||||||
|
Object.entries(fieldErrors).forEach(([k, v]) => {
|
||||||
|
errors[k] = Array.isArray(v) ? v[0] : String(v)
|
||||||
|
})
|
||||||
|
serverError.value = 'Certains champs sont invalides — vérifie les erreurs ci-dessus.'
|
||||||
|
} else {
|
||||||
|
serverError.value = 'Une erreur s\'est produite. Réessaie dans quelques instants.'
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
Object.assign(form, {
|
||||||
|
nom: '', url: '', description_user: '', echelle: '',
|
||||||
|
fonctions: [], territoire: '', localisation_ville: '', submitted_by_email: '',
|
||||||
|
})
|
||||||
|
Object.keys(errors).forEach(k => delete errors[k])
|
||||||
|
success.value = false
|
||||||
|
serverError.value = ''
|
||||||
|
trackingUrl.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Meta ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
useHead({ title: 'Proposer une ressource — AEP' })
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* ── Layout ─────────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.contribuer-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: var(--nav-bg);
|
||||||
|
padding: 1.5rem 1rem 4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contribuer-inner {
|
||||||
|
max-width: 640px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Retour ──────────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.back-link {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--nav-primary-solid);
|
||||||
|
opacity: 0.7;
|
||||||
|
text-decoration: none;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-link:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── En-tête ─────────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.contribuer-header {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contribuer-header h1 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--nav-text);
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contribuer-subtitle {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--nav-text-muted);
|
||||||
|
line-height: 1.5;
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contribuer-hint {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--nav-text-muted);
|
||||||
|
opacity: 0.75;
|
||||||
|
line-height: 1.5;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Succès ──────────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.success-block {
|
||||||
|
background: var(--nav-surface);
|
||||||
|
border: 1px solid rgba(26, 34, 56, 0.15);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 2rem 1.5rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-icon {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
background: rgba(26, 34, 56, 0.1);
|
||||||
|
color: var(--nav-text);
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0 auto 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-block h2 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--nav-text);
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-block p {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--nav-text-muted);
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-detail {
|
||||||
|
font-size: 0.85rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-tracking {
|
||||||
|
font-size: 0.85rem !important;
|
||||||
|
margin-top: 1rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tracking-link {
|
||||||
|
color: var(--nav-primary-solid);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Formulaire ──────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.contribuer-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Champ générique ─────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.field-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-group label,
|
||||||
|
.field-group legend {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--nav-text);
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-group fieldset {
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.required {
|
||||||
|
color: #c0392b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label-hint {
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--nav-text-muted);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
margin-left: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-group input[type="text"],
|
||||||
|
.field-group input[type="url"],
|
||||||
|
.field-group input[type="email"],
|
||||||
|
.field-group textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.625rem 0.875rem;
|
||||||
|
border: 1px solid rgba(26, 34, 56, 0.2);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--nav-text);
|
||||||
|
background: var(--nav-surface);
|
||||||
|
font-family: inherit;
|
||||||
|
transition: border-color 0.15s, box-shadow 0.15s;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-group input:focus,
|
||||||
|
.field-group textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--nav-primary-solid);
|
||||||
|
box-shadow: 0 0 0 2px rgba(245, 179, 66, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-group textarea {
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Erreur champ */
|
||||||
|
|
||||||
|
.field-error input,
|
||||||
|
.field-error textarea {
|
||||||
|
border-color: #c0392b !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-msg {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #c0392b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-meta {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.char-count {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--nav-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.char-warn {
|
||||||
|
color: #e67e22;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Radio (Échelle + Territoire) ────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.radio-group {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radio-label {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
padding: 0.375rem 0.75rem;
|
||||||
|
border: 1px solid rgba(26, 34, 56, 0.2);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--nav-text);
|
||||||
|
background: var(--nav-surface);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radio-label input[type="radio"] {
|
||||||
|
position: absolute;
|
||||||
|
opacity: 0;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radio-label:hover {
|
||||||
|
border-color: var(--nav-primary-solid);
|
||||||
|
background: var(--nav-bg-alt);
|
||||||
|
}
|
||||||
|
|
||||||
|
.radio-label.active {
|
||||||
|
background: var(--nav-primary);
|
||||||
|
border-color: transparent;
|
||||||
|
color: var(--nav-text-on-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Checkboxes (Fonctions) ──────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.checkbox-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 400px) {
|
||||||
|
.checkbox-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-label {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
padding: 0.375rem 0.75rem;
|
||||||
|
border: 1px solid rgba(26, 34, 56, 0.2);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--nav-text);
|
||||||
|
background: var(--nav-surface);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
user-select: none;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-label input[type="checkbox"] {
|
||||||
|
position: absolute;
|
||||||
|
opacity: 0;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-label:hover:not(.disabled) {
|
||||||
|
border-color: var(--nav-primary-solid);
|
||||||
|
background: var(--nav-bg-alt);
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-label.active {
|
||||||
|
background: var(--nav-primary);
|
||||||
|
border-color: transparent;
|
||||||
|
color: var(--nav-text-on-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-label.disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fn-order {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
background: var(--nav-accent);
|
||||||
|
color: var(--nav-text);
|
||||||
|
border-radius: 50%;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 700;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Erreur serveur ──────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.server-error {
|
||||||
|
padding: 0.875rem 1rem;
|
||||||
|
background: #fdf0ee;
|
||||||
|
border: 1px solid #e74c3c;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #c0392b;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Actions ──────────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
background: var(--nav-primary);
|
||||||
|
color: var(--nav-text-on-primary);
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
transition: background 0.15s, opacity 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover:not(:disabled) {
|
||||||
|
background: rgba(26, 34, 56, 0.75);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
padding: 0.75rem 1.25rem;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--nav-text-muted);
|
||||||
|
border: 1px solid rgba(26, 34, 56, 0.2);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
transition: border-color 0.15s, color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
border-color: var(--nav-primary-solid);
|
||||||
|
color: var(--nav-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-note {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--nav-text-muted);
|
||||||
|
text-align: center;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Responsive ──────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.contribuer-page {
|
||||||
|
padding: 1rem 0.75rem 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
flex-direction: column-reverse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary,
|
||||||
|
.btn-secondary {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,150 +0,0 @@
|
|||||||
<!--
|
|
||||||
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>
|
|
||||||
+46
-191
@@ -40,35 +40,10 @@
|
|||||||
Mode dev — données seed
|
Mode dev — données seed
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ── VUE DESKTOP : Onglets Métropole / Outre-mer / Toutes les fiches ── -->
|
<!-- ── VUE DESKTOP : Métropole pleine largeur + DOM-TOM row en bas ── -->
|
||||||
<div class="hidden lg:flex lg:flex-1 lg:flex-col lg:overflow-hidden">
|
<div class="hidden lg:flex lg:flex-1 lg:flex-col lg:overflow-hidden">
|
||||||
<!-- Barre onglets desktop -->
|
<!-- Carte Métropole — pleine largeur -->
|
||||||
<div class="shrink-0 flex" style="background: var(--nav-surface); border-bottom: 1px solid var(--nav-bg-alt);">
|
<div class="flex flex-col flex-1 overflow-hidden">
|
||||||
<button
|
|
||||||
class="px-5 py-2 text-sm font-medium transition-colors"
|
|
||||||
:style="desktopMapView === 'metropole'
|
|
||||||
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
|
|
||||||
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
|
|
||||||
@click="setDesktopView('metropole')"
|
|
||||||
>Métropolitain</button>
|
|
||||||
<button
|
|
||||||
class="px-5 py-2 text-sm font-medium transition-colors"
|
|
||||||
:style="desktopMapView === 'outremer'
|
|
||||||
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
|
|
||||||
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
|
|
||||||
@click="setDesktopView('outremer')"
|
|
||||||
>Outre-mer</button>
|
|
||||||
<button
|
|
||||||
class="px-5 py-2 text-sm font-medium transition-colors"
|
|
||||||
:style="desktopMapView === 'fiches'
|
|
||||||
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
|
|
||||||
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
|
|
||||||
@click="setDesktopView('fiches')"
|
|
||||||
>Toutes les fiches</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Carte Métropole desktop -->
|
|
||||||
<div v-show="desktopMapView === 'metropole'" class="flex-1 flex flex-col overflow-hidden">
|
|
||||||
<div class="relative flex-1" style="min-height: 200px;">
|
<div class="relative flex-1" style="min-height: 200px;">
|
||||||
<ClientOnly>
|
<ClientOnly>
|
||||||
<NavMap
|
<NavMap
|
||||||
@@ -78,16 +53,23 @@
|
|||||||
@select-org="onSelectOrg"
|
@select-org="onSelectOrg"
|
||||||
/>
|
/>
|
||||||
<template #fallback>
|
<template #fallback>
|
||||||
<div class="w-full h-full flex items-center justify-center" style="background: var(--nav-bg-alt); color: var(--nav-text-muted);">Chargement de la carte…</div>
|
<div
|
||||||
|
class="w-full h-full flex items-center justify-center"
|
||||||
|
style="background: var(--nav-bg-alt); color: var(--nav-text-muted);"
|
||||||
|
>
|
||||||
|
Chargement de la carte…
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</ClientOnly>
|
</ClientOnly>
|
||||||
</div>
|
</div>
|
||||||
<ChatbotPlaceholder @highlightOrgs="onHighlightOrgs" />
|
<ChatbotPlaceholder @highlightOrgs="onHighlightOrgs" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Carte Outre-mer desktop -->
|
<!-- Bandeau DOM-TOM — row horizontale pleine largeur, hauteur fixe -->
|
||||||
<div v-show="desktopMapView === 'outremer'" class="flex-1 flex flex-col overflow-hidden">
|
<div
|
||||||
<div class="flex-1 overflow-y-auto">
|
class="shrink-0"
|
||||||
|
style="height: 140px; border-top: 1px solid var(--nav-bg-alt);"
|
||||||
|
>
|
||||||
<ClientOnly>
|
<ClientOnly>
|
||||||
<OutremerMap
|
<OutremerMap
|
||||||
:orgs="outremerOrgs"
|
:orgs="outremerOrgs"
|
||||||
@@ -95,64 +77,35 @@
|
|||||||
@select-org="onSelectOrg"
|
@select-org="onSelectOrg"
|
||||||
/>
|
/>
|
||||||
<template #fallback>
|
<template #fallback>
|
||||||
<div class="flex items-center justify-center h-full text-sm" style="color: var(--nav-text-muted);">Chargement…</div>
|
<div
|
||||||
|
class="flex items-center justify-center h-full text-sm"
|
||||||
|
style="color: var(--nav-text-muted);"
|
||||||
|
>
|
||||||
|
Chargement…
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</ClientOnly>
|
</ClientOnly>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- ── VUE MOBILE : Onglets Métro/Outre-mer + carte pleine hauteur + sheet swipable ── -->
|
<!-- ── VUE MOBILE : Onglets Métro/Outre-mer + carte pleine hauteur + sheet swipable ── -->
|
||||||
|
|
||||||
<!-- Onglets Métropolitain / Outre-mer / Toutes les fiches -->
|
<!-- Onglets Métropolitain / Outre-mer -->
|
||||||
<div class="lg:hidden shrink-0 flex" style="background: var(--nav-surface); border-bottom: 1px solid var(--nav-bg-alt);">
|
<div class="lg:hidden shrink-0 flex" style="background: var(--nav-surface); border-bottom: 1px solid var(--nav-bg-alt);">
|
||||||
<button
|
<button
|
||||||
class="flex-1 py-2 text-sm font-medium transition-colors"
|
class="flex-1 py-2 text-sm font-medium transition-colors"
|
||||||
:style="mobileMapView === 'metropole'
|
:style="mobileMapView === 'metropole'
|
||||||
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
|
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
|
||||||
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
|
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
|
||||||
@click="setMobileView('metropole')"
|
@click="mobileMapView = 'metropole'"
|
||||||
>Métropolitain</button>
|
>Métropolitain</button>
|
||||||
<button
|
<button
|
||||||
class="flex-1 py-2 text-sm font-medium transition-colors"
|
class="flex-1 py-2 text-sm font-medium transition-colors"
|
||||||
:style="mobileMapView === 'outremer'
|
:style="mobileMapView === 'outremer'
|
||||||
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
|
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
|
||||||
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
|
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
|
||||||
@click="setMobileView('outremer')"
|
@click="mobileMapView = 'outremer'"
|
||||||
>Outre-mer</button>
|
>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>
|
||||||
|
|
||||||
<div class="lg:hidden flex-1 relative overflow-hidden">
|
<div class="lg:hidden flex-1 relative overflow-hidden">
|
||||||
@@ -193,30 +146,8 @@
|
|||||||
</ClientOnly>
|
</ClientOnly>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Zone « Fiches » mobile (B3-M3) — pleine hauteur sous la barre d'onglets,
|
<!-- Bottom sheet swipable (Métropole et Outre-mer) -->
|
||||||
pas de carte dessous donc pas de sheet demi-hauteur (même logique que
|
<ClientOnly>
|
||||||
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">
|
<MobileSheet :resultCount="filtered.length" :pending="pending">
|
||||||
<!-- Barre recherche -->
|
<!-- Barre recherche -->
|
||||||
<div class="px-3 pt-2 pb-2" style="border-bottom: 1px solid var(--nav-bg-alt);">
|
<div class="px-3 pt-2 pb-2" style="border-bottom: 1px solid var(--nav-bg-alt);">
|
||||||
@@ -246,36 +177,36 @@
|
|||||||
</button>
|
</button>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<!-- Filtres ÉCHELLE — Chip variant echelle (B3-M1 : alignées sur EchelleFilter.vue,
|
<!-- Filtres ÉCHELLE — chips style FONCTION -->
|
||||||
dupliquées ici car la sheet mobile ne passe pas par NavSidebar) -->
|
|
||||||
<div class="mt-2">
|
<div class="mt-2">
|
||||||
<span class="text-xs font-bold uppercase tracking-wide block mb-1" style="color: var(--nav-text-muted);">ÉCHELLE</span>
|
<span class="text-xs font-bold uppercase tracking-wide block mb-1" style="color: var(--nav-text-muted);">ÉCHELLE</span>
|
||||||
<ChipGroup aria-label="Filtrer par échelle">
|
<div class="flex flex-wrap gap-1">
|
||||||
<Chip
|
<span
|
||||||
v-for="opt in ECHELLES"
|
v-for="opt in ECHELLES"
|
||||||
:key="opt"
|
:key="opt"
|
||||||
:label="opt"
|
class="cursor-pointer px-2 py-0.5 rounded-full text-xs transition-all"
|
||||||
variant="echelle"
|
:style="echelle.includes(opt)
|
||||||
:active="echelle.includes(opt)"
|
? 'background: var(--nav-primary); color: var(--nav-text-on-primary); font-weight: 600;'
|
||||||
@toggle="toggleEchelle(opt)"
|
: 'background: var(--nav-bg-alt); color: var(--nav-text-muted);'"
|
||||||
/>
|
@click="toggleEchelle(opt)"
|
||||||
</ChipGroup>
|
>{{ opt }}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Filtres FONCTION — Chip variant fonction (B3-M1, idem) -->
|
<!-- Filtres FONCTION — chips flex-wrap -->
|
||||||
<div class="mt-2">
|
<div class="mt-2">
|
||||||
<span class="text-xs font-bold uppercase tracking-wide block mb-1" style="color: var(--nav-text-muted);">FONCTION</span>
|
<span class="text-xs font-bold uppercase tracking-wide block mb-1" style="color: var(--nav-text-muted);">FONCTION</span>
|
||||||
<ChipGroup aria-label="Filtrer par fonction">
|
<div class="flex flex-wrap gap-1">
|
||||||
<Chip
|
<span
|
||||||
v-for="fn in FONCTIONS"
|
v-for="fn in FONCTIONS"
|
||||||
:key="fn"
|
:key="fn"
|
||||||
:label="fn"
|
class="cursor-pointer px-2 py-0.5 rounded-full text-xs transition-all"
|
||||||
variant="fonction"
|
:style="fonctions.includes(fn)
|
||||||
:active="fonctions.includes(fn)"
|
? 'background: var(--nav-primary); color: var(--nav-text-on-primary); font-weight: 600;'
|
||||||
:count="fonctions.includes(fn) ? (fonctions.indexOf(fn) + 1) : (fonctionCount[fn] ?? 0)"
|
: 'background: var(--nav-bg-alt); color: var(--nav-text-muted);'"
|
||||||
@toggle="toggleFonction(fn)"
|
@click="toggleFonction(fn)"
|
||||||
/>
|
>{{ fn }}</span>
|
||||||
</ChipGroup>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@@ -373,27 +304,6 @@
|
|||||||
@highlightOrgs="onHighlightOrgs"
|
@highlightOrgs="onHighlightOrgs"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- ═══════════════════════════════════════ POP-UP MISSION ENTRAIDE -->
|
|
||||||
<button
|
|
||||||
class="mission-info-btn"
|
|
||||||
type="button"
|
|
||||||
@click="missionOpen = true"
|
|
||||||
aria-label="À propos de cette carte d'entraide"
|
|
||||||
title="À propos de cette carte"
|
|
||||||
>
|
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
||||||
<circle cx="12" cy="12" r="10"/>
|
|
||||||
<line x1="12" y1="16" x2="12" y2="12"/>
|
|
||||||
<line x1="12" y1="8" x2="12.01" y2="8"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<MissionPopup
|
|
||||||
:modelValue="missionOpen"
|
|
||||||
@update:modelValue="missionOpen = $event"
|
|
||||||
echelle="M"
|
|
||||||
/>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -420,23 +330,11 @@ const territoireMode = ref<string>(
|
|||||||
(route.query.mode as string) === 'outremer' ? 'outremer' : 'metropole'
|
(route.query.mode as string) === 'outremer' ? '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 selectedId = ref<number | null>(null)
|
||||||
const chatbotOpen = ref(false)
|
const chatbotOpen = ref(false)
|
||||||
const ficheModalOpen = ref(false)
|
const ficheModalOpen = ref(false)
|
||||||
const ficheModalId = ref<number | null>(null)
|
const ficheModalId = ref<number | null>(null)
|
||||||
const mobileMapView = ref<MapView>((route.query.vue as string) === 'fiches' ? 'fiches' : 'metropole')
|
const mobileMapView = ref<'metropole' | 'outremer'>('metropole')
|
||||||
const missionOpen = ref(false)
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
try {
|
|
||||||
if (!localStorage.getItem('aep_mission_seen')) {
|
|
||||||
missionOpen.value = true
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
})
|
|
||||||
// Surlignage temporaire (5 sec) suite à une réponse chatbot
|
// Surlignage temporaire (5 sec) suite à une réponse chatbot
|
||||||
// → sélectionne le premier ID recommandé sur la carte, puis remet à null
|
// → sélectionne le premier ID recommandé sur la carte, puis remet à null
|
||||||
let highlightTimer: ReturnType<typeof setTimeout> | null = null
|
let highlightTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
@@ -475,20 +373,9 @@ function syncUrl() {
|
|||||||
if (fonctions.value.length) q.fonctions = fonctions.value.join(',')
|
if (fonctions.value.length) q.fonctions = fonctions.value.join(',')
|
||||||
if (territoire.value) q.territoire = territoire.value
|
if (territoire.value) q.territoire = territoire.value
|
||||||
if (territoireMode.value === 'outremer') q.mode = 'outremer'
|
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 })
|
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
|
// Sauvegarde les filtres courants dans sessionStorage pour le bouton retour des fiches
|
||||||
function storeFiltersForBack() {
|
function storeFiltersForBack() {
|
||||||
if (typeof window === 'undefined') return
|
if (typeof window === 'undefined') return
|
||||||
@@ -616,12 +503,6 @@ const filtered = computed<Org[]>(() => {
|
|||||||
return result
|
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 = ['Guadeloupe', 'Martinique', 'Guyane', 'La Réunion', 'Mayotte']
|
||||||
const DOM_TOM_LIST = DOM_TOM
|
const DOM_TOM_LIST = DOM_TOM
|
||||||
|
|
||||||
@@ -685,29 +566,3 @@ function fonctionsList(org: Org): string[] {
|
|||||||
|
|
||||||
useHead({ title: 'AEP — Cartographie de l\'écologie politique architecturale' })
|
useHead({ title: 'AEP — Cartographie de l\'écologie politique architecturale' })
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.mission-info-btn {
|
|
||||||
position: fixed;
|
|
||||||
bottom: 24px;
|
|
||||||
left: 16px;
|
|
||||||
z-index: 1000;
|
|
||||||
width: 36px;
|
|
||||||
height: 36px;
|
|
||||||
border-radius: 50%;
|
|
||||||
border: none;
|
|
||||||
background: var(--nav-surface);
|
|
||||||
color: var(--nav-text-muted);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
box-shadow: 0 2px 12px rgba(26,34,56,0.18);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: opacity 0.15s, transform 0.1s;
|
|
||||||
}
|
|
||||||
.mission-info-btn:hover { opacity: 0.85; transform: translateY(-1px); color: var(--nav-text); }
|
|
||||||
|
|
||||||
@media (min-width: 1024px) {
|
|
||||||
.mission-info-btn { bottom: 16px; left: 340px; }
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -1,239 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="manifeste-page">
|
|
||||||
<div class="manifeste-inner">
|
|
||||||
|
|
||||||
<NuxtLink to="/" class="back-link">← Retour à la carte</NuxtLink>
|
|
||||||
|
|
||||||
<h1 class="manifeste-title">Manifeste — Architecture d'Écologie Politique</h1>
|
|
||||||
|
|
||||||
<p class="lede">
|
|
||||||
<em>Un quart des architectes vivent sous le seuil de pauvreté. La moitié de nos heures, non facturées. Nos cotisations, parmi les plus lourdes des professions réglementées. Et le secteur du bâtiment, à lui seul, pèse 34 % des émissions mondiales de gaz à effet de serre.</em>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p>
|
|
||||||
Quelque chose s'est rompu — pas dans nos vies, dans les cadres qui les contiennent.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p>
|
|
||||||
Notre profession ne traverse pas une simple crise. Elle reflète l'effondrement d'un monde qui confond performance et destruction, signature et silence, expertise et soumission.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<hr />
|
|
||||||
|
|
||||||
<h2>Ce que nous voyons.</h2>
|
|
||||||
|
|
||||||
<p>
|
|
||||||
À l'échelle du métier, une profession structurellement sous l'eau, qui absorbe les tensions d'un système extractiviste — et porte la responsabilité quand d'autres captent la valeur.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p>
|
|
||||||
À l'échelle des corps, une culture qui rend l'exploitation désirable : métier-passion, modèle starchitecte, isolement libéral, moteur critique délégitimant. Nous tenons. Nous payons.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p>
|
|
||||||
À l'échelle du monde, l'effondrement écologique et social qui avance, pendant que notre voix s'efface du débat public. Notre silence le sert.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<hr />
|
|
||||||
|
|
||||||
<h2>Ce que nous refusons.</h2>
|
|
||||||
|
|
||||||
<p class="refus">
|
|
||||||
Nous ne signerons plus pour des projets qui détruisent.<br />
|
|
||||||
Nous n'isolerons plus celles et ceux qui doutent.<br />
|
|
||||||
Nous ne porterons plus seul·es ce qui doit se penser, se faire — et se soigner — ensemble.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<hr />
|
|
||||||
|
|
||||||
<p class="pivot">
|
|
||||||
<strong>Et pourtant, quelque chose tient.</strong>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p class="pivot-suite">
|
|
||||||
Pas l'espoir naïf, ni la promesse héroïque. Quelque chose de plus humble : la fatigue commune reconnue, et l'envie qui revient de ne plus économiser sa vie.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<hr />
|
|
||||||
|
|
||||||
<h2>Ce que nous tentons.</h2>
|
|
||||||
|
|
||||||
<p>
|
|
||||||
<em>Partager.</em> Nos parcours, nos doutes, nos bifurcations. Se former les un·es les autres. Se tendre la main. Documenter ce qui marche, ce qui rate. Le personnel devient politique quand il se met en commun.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p>
|
|
||||||
<em>Construire.</em> L'infrastructure collective qui nous a manqué. Cartes d'entraide, communs documentés, gouvernance horizontale, financement transparent, infra souveraine. <strong>Architecture d'Écologie Politique</strong> : un commun vivant, ouvert, biorégional, ancré.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p>
|
|
||||||
<em>Pratiquer une médecine du corps social.</em> Diagnostiquer les infrastructures qui défaillent — l'éducation, la justice, la sécurité, l'énergie, la santé, le logement, l'agriculture. Proposer des reconfigurations situées, territoire par territoire. Reprendre le pouvoir par la base. Écrire, lentement, un nouveau contrat social.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p>
|
|
||||||
<em>Commencer par les marges.</em> Là où le corps social souffre le plus, là où il est le plus prêt à changer. Ne pas décider à la place — faire émerger. Transparence totale, sur le process et sur l'argent. Tendresse militante : la lucidité sans le mépris, l'engagement sans la dureté.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<hr />
|
|
||||||
|
|
||||||
<h2>Architectes, allié·es, habitant·es.</h2>
|
|
||||||
|
|
||||||
<p>
|
|
||||||
Nous avons un travail à faire ensemble. Lentement, patiemment, par accumulation de petits gestes situés. Pas pour fuir — pour bifurquer.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p class="chute">
|
|
||||||
<em>Nos métiers sont des médecines. Reprenons-en le pouls — à mains nues, ensemble.</em>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<hr />
|
|
||||||
|
|
||||||
<p class="cta-wrap">
|
|
||||||
<a
|
|
||||||
href="https://www.trans-former.fr/"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
class="btn-blog"
|
|
||||||
>
|
|
||||||
En lire plus — blog AEP →
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
useHead({
|
|
||||||
title: 'Manifeste — AEP',
|
|
||||||
meta: [
|
|
||||||
{ name: 'description', content: 'Manifeste d\'Architecture d\'Écologie Politique — un commun vivant pour bifurquer ensemble.' },
|
|
||||||
],
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.manifeste-page {
|
|
||||||
min-height: 100vh;
|
|
||||||
background: var(--nav-bg);
|
|
||||||
padding: 1.5rem 1rem 5rem;
|
|
||||||
overflow-x: hidden;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.manifeste-inner {
|
|
||||||
max-width: 680px;
|
|
||||||
margin: 0 auto;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.back-link {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.25rem;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
color: var(--nav-primary-solid);
|
|
||||||
opacity: 0.7;
|
|
||||||
text-decoration: none;
|
|
||||||
margin-bottom: 2rem;
|
|
||||||
transition: opacity 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.back-link:hover { opacity: 1; }
|
|
||||||
|
|
||||||
.manifeste-title {
|
|
||||||
font-size: 1.65rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--nav-text);
|
|
||||||
margin: 0 0 1.5rem;
|
|
||||||
line-height: 1.25;
|
|
||||||
}
|
|
||||||
|
|
||||||
.lede {
|
|
||||||
font-size: 1rem;
|
|
||||||
line-height: 1.7;
|
|
||||||
color: var(--nav-text);
|
|
||||||
margin: 0 0 1.25rem;
|
|
||||||
border-left: 3px solid var(--nav-primary-solid);
|
|
||||||
padding-left: 1rem;
|
|
||||||
opacity: 0.85;
|
|
||||||
}
|
|
||||||
|
|
||||||
p {
|
|
||||||
font-size: 0.975rem;
|
|
||||||
line-height: 1.75;
|
|
||||||
color: var(--nav-text);
|
|
||||||
margin: 0 0 1.1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
h2 {
|
|
||||||
font-size: 1.05rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--nav-text);
|
|
||||||
margin: 2rem 0 1rem;
|
|
||||||
letter-spacing: 0.01em;
|
|
||||||
}
|
|
||||||
|
|
||||||
hr {
|
|
||||||
border: none;
|
|
||||||
border-top: 1px solid var(--nav-bg-alt);
|
|
||||||
margin: 2rem 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.refus {
|
|
||||||
font-style: normal;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pivot {
|
|
||||||
font-size: 1.15rem;
|
|
||||||
text-align: center;
|
|
||||||
margin: 2rem 0 1rem;
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pivot strong {
|
|
||||||
font-weight: 700;
|
|
||||||
font-style: normal;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pivot-suite {
|
|
||||||
text-align: center;
|
|
||||||
font-style: italic;
|
|
||||||
opacity: 0.85;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chute {
|
|
||||||
font-size: 1.05rem;
|
|
||||||
text-align: center;
|
|
||||||
margin-top: 1.5rem;
|
|
||||||
color: var(--nav-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.cta-wrap {
|
|
||||||
text-align: center;
|
|
||||||
margin: 2rem 0 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-blog {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: 0.75rem 1.5rem;
|
|
||||||
background: var(--nav-primary);
|
|
||||||
color: var(--nav-text-on-primary);
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
font-weight: 600;
|
|
||||||
text-decoration: none;
|
|
||||||
transition: opacity 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-blog:hover { opacity: 0.85; }
|
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
|
||||||
.manifeste-page { padding: 1rem 0.85rem 4rem; }
|
|
||||||
.manifeste-title { font-size: 1.4rem; }
|
|
||||||
.lede { font-size: 0.95rem; padding-left: 0.85rem; }
|
|
||||||
p { font-size: 0.95rem; }
|
|
||||||
.pivot { font-size: 1.05rem; }
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="media-page" style="background: var(--nav-bg);">
|
|
||||||
<nav class="subtabs" style="display:flex; gap:0; border-bottom: 1px solid var(--nav-bg-alt); background: var(--nav-surface); padding: 0 1rem;">
|
|
||||||
<button
|
|
||||||
:class="['subtab-btn', { active: tab === 'visuel' }]"
|
|
||||||
@click="tab = 'visuel'"
|
|
||||||
>
|
|
||||||
📚 bibliothèque des pensées écologiques
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
:class="['subtab-btn', { active: tab === 'projets' }]"
|
|
||||||
@click="tab = 'projets'"
|
|
||||||
>
|
|
||||||
📐 Projets
|
|
||||||
</button>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<MediaTabVisuel v-if="tab === 'visuel'" />
|
|
||||||
<MediaTabProjets v-else-if="tab === 'projets'" />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
const route = useRoute()
|
|
||||||
const router = useRouter()
|
|
||||||
|
|
||||||
const tab = ref<'visuel' | 'projets'>(
|
|
||||||
(['visuel', 'projets'].includes(route.query.tab as string)
|
|
||||||
? route.query.tab as 'visuel' | 'projets'
|
|
||||||
: 'visuel')
|
|
||||||
)
|
|
||||||
|
|
||||||
watch(tab, (newTab) => {
|
|
||||||
router.replace({ query: { ...route.query, tab: newTab } })
|
|
||||||
})
|
|
||||||
|
|
||||||
useHead({ title: 'AEP - Media' })
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.media-page { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
|
|
||||||
.subtabs { display: flex; gap: 0; flex-shrink: 0; }
|
|
||||||
.subtab-btn {
|
|
||||||
padding: 10px 18px;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
font-weight: 500;
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
border-bottom: 2px solid transparent;
|
|
||||||
cursor: pointer;
|
|
||||||
color: var(--nav-text-muted);
|
|
||||||
transition: color 0.15s, border-color 0.15s;
|
|
||||||
}
|
|
||||||
.subtab-btn:hover { color: var(--nav-text); }
|
|
||||||
.subtab-btn.active { color: var(--nav-primary-solid); border-bottom-color: var(--nav-primary-solid); font-weight: 600; }
|
|
||||||
</style>
|
|
||||||
@@ -1,797 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="outils-page">
|
|
||||||
|
|
||||||
<!-- ══════════════════════ EN-TÊTE PAGE ══════════════════════ -->
|
|
||||||
<header class="outils-header">
|
|
||||||
<div class="outils-header__inner">
|
|
||||||
<div class="outils-header__icon-wrap" aria-hidden="true">
|
|
||||||
<img src="/icons/outils-wrench.svg" alt="" class="outils-header__icon" />
|
|
||||||
</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
|
|
||||||
utiliser quand : c'est une forme d'entraide concrète. Voici ceux que je propose
|
|
||||||
dans un premier temps. Chacun peut contribuer pour enrichir cette boîte à outils
|
|
||||||
commune.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main class="outils-main">
|
|
||||||
|
|
||||||
<!-- ══════════════ SECTION 1 — Simulateurs métier ══════════════ -->
|
|
||||||
<section class="outils-section outils-section--simulateurs" aria-labelledby="sec-simulateurs">
|
|
||||||
<h2 id="sec-simulateurs" class="outils-section__title">
|
|
||||||
<span aria-hidden="true">🧮</span> Simulateurs métier
|
|
||||||
</h2>
|
|
||||||
<p class="outils-section__subtitle">Créés par AEP — outils de calcul situés.</p>
|
|
||||||
|
|
||||||
<div class="simu-grid">
|
|
||||||
<SimulateurFeature
|
|
||||||
v-for="s in outils?.simulateurs"
|
|
||||||
:key="s.id"
|
|
||||||
:icon="s.icon"
|
|
||||||
:titre="s.titre"
|
|
||||||
:url="s.url"
|
|
||||||
:description="s.description"
|
|
||||||
:cta="s.cta"
|
|
||||||
:tag="s.tag"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Inspirations -->
|
|
||||||
<div v-if="outils?.simulateurs_inspirations?.length" class="simu-inspirations">
|
|
||||||
<p class="simu-inspirations__label">Inspiration externe</p>
|
|
||||||
<div class="outil-cards-grid">
|
|
||||||
<OutilCard
|
|
||||||
v-for="s in outils.simulateurs_inspirations"
|
|
||||||
:key="s.id"
|
|
||||||
:icon="s.icon"
|
|
||||||
:titre="s.titre"
|
|
||||||
:url="s.url"
|
|
||||||
:description="s.description"
|
|
||||||
:tag="s.tag"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- ══════════════ SECTION 2 — Open source recommandés ══════════════ -->
|
|
||||||
<section class="outils-section outils-section--opensource" aria-labelledby="sec-opensource">
|
|
||||||
<h2 id="sec-opensource" class="outils-section__title">
|
|
||||||
<span aria-hidden="true">🔧</span> Outils tech open source
|
|
||||||
</h2>
|
|
||||||
<p class="outils-section__subtitle">Quelques recommandations directes. Le cœur de l'onglet, c'est la section FMHY plus bas.</p>
|
|
||||||
|
|
||||||
<div class="outil-cards-grid">
|
|
||||||
<OutilCard
|
|
||||||
v-for="outil in outils?.opensource"
|
|
||||||
:key="outil.id"
|
|
||||||
:icon="outil.icon"
|
|
||||||
:titre="outil.titre"
|
|
||||||
:url="outil.url"
|
|
||||||
:description="outil.description"
|
|
||||||
:tag="outil.tag"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- ══════════════ SECTION 3 — Bifurcation ══════════════ -->
|
|
||||||
<section class="outils-section" aria-labelledby="sec-bifurcation">
|
|
||||||
<h2 id="sec-bifurcation" class="outils-section__title">
|
|
||||||
<span aria-hidden="true">🌿</span> Bifurcation post-études d'archi
|
|
||||||
</h2>
|
|
||||||
<p class="outils-section__desc">
|
|
||||||
{{ outils?.bifurcation?.intro }}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<!-- 3.1 Vidéos OFQA -->
|
|
||||||
<div class="bifurcation-block">
|
|
||||||
<h3 class="bifurcation-block__title">Série vidéo OFQA / ENSA-PB</h3>
|
|
||||||
<ul class="ofqa-list">
|
|
||||||
<li
|
|
||||||
v-for="ep in outils?.bifurcation?.videos_ofqa"
|
|
||||||
:key="ep.ep"
|
|
||||||
class="ofqa-list__item"
|
|
||||||
>
|
|
||||||
<component
|
|
||||||
:is="ep.url ? 'a' : 'span'"
|
|
||||||
v-bind="ep.url ? { href: ep.url, target: '_blank', rel: 'noopener noreferrer' } : {}"
|
|
||||||
class="ofqa-list__link"
|
|
||||||
:class="{ 'ofqa-list__link--disabled': !ep.url }"
|
|
||||||
>
|
|
||||||
<span class="ofqa-list__ep">EP/{{ ep.ep }}</span>
|
|
||||||
<span class="ofqa-list__titre">{{ ep.titre }}</span>
|
|
||||||
<span class="ofqa-list__personnes">— {{ ep.personnes }}</span>
|
|
||||||
<span v-if="ep.note" class="ofqa-list__note">({{ ep.note }})</span>
|
|
||||||
</component>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 3.2 Coalition -->
|
|
||||||
<div v-if="outils?.bifurcation?.coalition_ensa_pb" class="bifurcation-block bifurcation-block--coalition">
|
|
||||||
<h3 class="bifurcation-block__title">{{ outils.bifurcation.coalition_ensa_pb.titre }}</h3>
|
|
||||||
<p class="bifurcation-block__desc">{{ outils.bifurcation.coalition_ensa_pb.description }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 3.3 Ressources externes -->
|
|
||||||
<div v-if="outils?.bifurcation?.ressources_externes?.length" class="bifurcation-block">
|
|
||||||
<h3 class="bifurcation-block__title">Ressources externes</h3>
|
|
||||||
<div class="outil-cards-grid">
|
|
||||||
<OutilCard
|
|
||||||
v-for="r in outils.bifurcation.ressources_externes"
|
|
||||||
:key="r.id"
|
|
||||||
:icon="r.icon"
|
|
||||||
:titre="r.titre"
|
|
||||||
:url="r.url"
|
|
||||||
:description="r.description"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- ══════════════ SECTION 4 — FMHY (cœur de la page) ══════════════ -->
|
|
||||||
<section class="outils-section outils-section--fmhy" aria-labelledby="sec-fmhy">
|
|
||||||
<h2 id="sec-fmhy" class="outils-section__title">
|
|
||||||
<span aria-hidden="true">🌳</span> Annuaire du web libre — la cartographie FMHY
|
|
||||||
</h2>
|
|
||||||
<p class="outils-section__desc">
|
|
||||||
FMHY (FreeMediaHeckYeah) est le plus grand index communautaire de ressources
|
|
||||||
gratuites du web : des milliers de liens classés et vérifiés par des centaines
|
|
||||||
de contributeurs bénévoles, mis à jour en continu. C'est une forme de
|
|
||||||
résistance numérique — accéder librement au savoir, à la culture et aux
|
|
||||||
outils, hors des catalogues marchands. J'en ai traduit et organisé les
|
|
||||||
entrées phares <strong>par usage</strong>, pour aller droit au but.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<!-- Note détaillée par usage -->
|
|
||||||
<div v-for="g in fmhyGuide" :key="g.id" class="fmhy-usage">
|
|
||||||
<h3 class="fmhy-usage__title">{{ g.titre }}</h3>
|
|
||||||
<p v-if="g.note" class="fmhy-usage__note">{{ g.note }}</p>
|
|
||||||
<ul class="fmhy-usage__list">
|
|
||||||
<li v-for="l in g.liens" :key="l.url" class="fmhy-usage__item">
|
|
||||||
<a
|
|
||||||
:href="l.url"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
class="fmhy-usage__link"
|
|
||||||
>{{ l.nom }}</a>
|
|
||||||
<span class="fmhy-usage__desc">— {{ l.desc }}</span>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Avertissement légal -->
|
|
||||||
<div class="fmhy-warning">
|
|
||||||
<h3 class="fmhy-warning__title">Avertissement</h3>
|
|
||||||
<p class="fmhy-warning__text">
|
|
||||||
Une partie des ressources indexées par FMHY relève du partage de contenus
|
|
||||||
protégés : la légalité dépend de ton pays et de l'usage que tu en fais.
|
|
||||||
Renseigne-toi avant d'utiliser ces liens. FMHY maintient une
|
|
||||||
<a href="https://fmhy.net/unsafe" target="_blank" rel="noopener noreferrer">liste des sites dangereux</a>
|
|
||||||
(malwares, arnaques) : consulte-la. En cas de doute, privilégie les sources
|
|
||||||
libres de droits (Project Gutenberg, Gallica, domaine public).
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p class="fmhy-snapshot">
|
|
||||||
Cartographie établie d'après un snapshot du 25 août 2026 — l'index fmhy
|
|
||||||
évolue en continu, voir
|
|
||||||
<a href="https://fmhy.net/" target="_blank" rel="noopener noreferrer">fmhy.net</a>.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<!-- Arbre curé interactif (sélection AEP) -->
|
|
||||||
<p class="fmhy-tree-label">Sélection interactive — clique sur les branches pour déplier</p>
|
|
||||||
<div class="fmhy-tree-wrap">
|
|
||||||
<div v-if="fmhyPending" class="fmhy-loading" aria-label="Chargement…">
|
|
||||||
<span>Chargement…</span>
|
|
||||||
</div>
|
|
||||||
<div v-else-if="fmhyError" class="fmhy-error">
|
|
||||||
Impossible de charger les ressources. <a href="https://fmhy.net/" target="_blank" rel="noopener noreferrer">Explorer fmhy.net →</a>
|
|
||||||
</div>
|
|
||||||
<TreeASCII v-else-if="fmhyData" :tree="fmhyData" :depth="0" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="fmhy-footer">
|
|
||||||
<a href="https://fmhy.net/" target="_blank" rel="noopener noreferrer" class="fmhy-footer__link">
|
|
||||||
Explorer tout l'arbre → fmhy.net
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- ══════════════ SECTION 5 — Placeholder login ══════════════ -->
|
|
||||||
<section class="outils-section outils-section--placeholder" aria-labelledby="sec-logiciels">
|
|
||||||
<div class="placeholder-block">
|
|
||||||
<span class="placeholder-block__badge">Bientôt — nécessite un compte</span>
|
|
||||||
<h2 id="sec-logiciels" class="placeholder-block__title">{{ outils?.section_5_placeholder?.titre }}</h2>
|
|
||||||
<p class="placeholder-block__desc">{{ outils?.section_5_placeholder?.description }}</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- ══════════════ FOOTER CONTRIBUTION ══════════════ -->
|
|
||||||
<footer class="outils-footer">
|
|
||||||
<p class="outils-footer__text">
|
|
||||||
{{ outils?.footer_contribution }}
|
|
||||||
</p>
|
|
||||||
</footer>
|
|
||||||
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
// Chargement des données
|
|
||||||
const { data: outils } = await useFetch('/data/outils.json')
|
|
||||||
const { data: fmhyData, pending: fmhyPending, error: fmhyError } = await useFetch('/data/fmhy-curated.json')
|
|
||||||
|
|
||||||
// Note détaillée FMHY — cartographie traduite et organisée par usage
|
|
||||||
// (entrées ★ « starred » de fmhy.net, snapshot du 25 août 2026)
|
|
||||||
const fmhyGuide = [
|
|
||||||
{
|
|
||||||
id: 'vie-privee',
|
|
||||||
titre: 'Vie privée & adblocking — les bases',
|
|
||||||
note: 'Le premier réflexe du guide débutant fmhy : un bon bloqueur de pubs rend la moitié du web à nouveau respirable.',
|
|
||||||
liens: [
|
|
||||||
{ nom: 'uBlock Origin', url: 'https://github.com/gorhill/uBlock', desc: 'bloqueur de pubs de référence, léger et open source' },
|
|
||||||
{ nom: 'SponsorBlock', url: 'https://sponsor.ajay.app/', desc: 'saute automatiquement les segments sponsorisés de YouTube' },
|
|
||||||
{ nom: 'LibreWolf', url: 'https://librewolf.net/', desc: 'Firefox durci vie privée, pré-configuré' },
|
|
||||||
{ nom: 'Proton VPN', url: 'https://protonvpn.com', desc: 'VPN suisse, offre gratuite illimitée' },
|
|
||||||
{ nom: 'Bitwarden', url: 'https://bitwarden.com/', desc: 'gestionnaire de mots de passe open source' },
|
|
||||||
{ nom: 'Cryptomator', url: 'https://cryptomator.org/', desc: 'chiffrement de tes fichiers avant envoi dans le cloud' },
|
|
||||||
{ nom: 'Tor Browser', url: 'https://www.torproject.org/', desc: 'navigation anonyme via le réseau Tor' },
|
|
||||||
{ nom: 'KeePassXC', url: 'https://keepassxc.org/', desc: 'coffre de mots de passe 100% local' }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'livres',
|
|
||||||
titre: 'Trouver un livre / ebook',
|
|
||||||
liens: [
|
|
||||||
{ nom: "Anna's Archive", url: 'https://annas-archive.gl/', desc: 'la plus grosse bibliothèque du web : livres, BD, articles, thèses' },
|
|
||||||
{ nom: 'Z-Library', url: 'https://z-lib.gd/', desc: 'catalogue immense, interface soignée' },
|
|
||||||
{ nom: 'Library Genesis', url: 'https://libgen.li/', desc: 'référence pour les livres techniques et académiques' },
|
|
||||||
{ nom: 'Mobilism', url: 'https://forum.mobilism.org', desc: 'ebooks, audiobooks, magazines (forum)' },
|
|
||||||
{ nom: 'Project Gutenberg', url: 'https://www.gutenberg.org/', desc: '70 000+ classiques du domaine public, 100% légal' },
|
|
||||||
{ nom: 'Sci-Hub', url: 'https://sci-hub.se/', desc: 'accès libre aux articles scientifiques derrière paywall' },
|
|
||||||
{ nom: 'Calibre', url: 'https://calibre-ebook.com/', desc: 'gestion et conversion de bibliothèque numérique' },
|
|
||||||
{ nom: 'KOReader', url: 'https://koreader.rocks/', desc: 'lecteur ebook open source pour liseuses' }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'films',
|
|
||||||
titre: 'Films, séries & anime',
|
|
||||||
liens: [
|
|
||||||
{ nom: 'Rive', url: 'https://www.rivestream.app/', desc: 'streaming films / séries / anime, auto-next' },
|
|
||||||
{ nom: 'Cineby', url: 'https://cineby.at/', desc: 'streaming films / séries / anime' },
|
|
||||||
{ nom: 'Flixer', url: 'https://flixer.gd', desc: 'streaming films / séries / anime' },
|
|
||||||
{ nom: 'Tubi', url: 'https://tubitv.com', desc: 'streaming légal et gratuit (financé par la pub)' },
|
|
||||||
{ nom: 'Stremio', url: 'https://www.stremio.com/', desc: 'centre multimédia extensible par extensions' },
|
|
||||||
{ nom: 'Miruro', url: 'https://www.miruro.com/', desc: 'anime en streaming' },
|
|
||||||
{ nom: 'Simkl', url: 'https://simkl.com/', desc: 'suivi de films, séries et anime déjà vus' }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'musique',
|
|
||||||
titre: 'Musique & podcasts',
|
|
||||||
liens: [
|
|
||||||
{ nom: 'Monochrome', url: 'https://monochrome.tf/', desc: 'streaming musique dans le navigateur' },
|
|
||||||
{ nom: 'lucida', url: 'https://lucida.to/', desc: 'téléchargement de musiques' },
|
|
||||||
{ nom: 'SpotX', url: 'https://github.com/SpotX-Official/SpotX', desc: 'Spotify desktop sans pub (patch client)' },
|
|
||||||
{ nom: 'NTS Radio', url: 'https://www.nts.live/', desc: 'shows de DJ indépendants, archives immenses' },
|
|
||||||
{ nom: 'SomaFM', url: 'https://somafm.com/', desc: '30+ radios internet indépendantes' },
|
|
||||||
{ nom: 'Radio Garden', url: 'https://radio.garden/', desc: 'explorer les radios du monde entier sur un globe' },
|
|
||||||
{ nom: 'Pocket Casts', url: 'https://play.pocketcasts.com/', desc: 'lecteur de podcasts libre et complet, aussi dans le navigateur' }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'logiciels',
|
|
||||||
titre: 'Logiciels & jeux vidéo',
|
|
||||||
liens: [
|
|
||||||
{ nom: 'qBittorrent', url: 'https://www.qbittorrent.org/', desc: 'client torrent libre, sans pub — la référence' },
|
|
||||||
{ nom: 'FitGirl Repacks', url: 'https://fitgirl-repacks.site/', desc: 'repacks de jeux ultra-compressés' },
|
|
||||||
{ nom: 'SteamRIP', url: 'https://steamrip.com/', desc: 'jeux pré-installés' },
|
|
||||||
{ nom: '1337x', url: 'https://1337x.to/', desc: 'annuaire torrents généraliste' }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'mobile',
|
|
||||||
titre: 'Android / iOS',
|
|
||||||
liens: [
|
|
||||||
{ nom: 'Aurora Store', url: 'https://auroraoss.com/', desc: 'Google Play alternatif, sans compte Google' },
|
|
||||||
{ nom: 'Droid-ify', url: 'https://droidify.app/', desc: 'client F-Droid moderne — applications libres' },
|
|
||||||
{ nom: 'APKMirror', url: 'https://www.apkmirror.com/', desc: 'archives APK vérifiées' },
|
|
||||||
{ nom: 'Fossify', url: 'https://github.com/FossifyOrg', desc: 'suite d’apps Android simples et libres (galerie, agenda, fichiers…)' },
|
|
||||||
{ nom: 'Obtainium', url: 'https://obtainium.imranr.dev/', desc: 'installe et met à jour tes APKs depuis les sources officielles' }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'education',
|
|
||||||
titre: 'Éducation, cours & recherche',
|
|
||||||
liens: [
|
|
||||||
{ nom: 'MIT OpenCourseWare', url: 'https://ocw.mit.edu/', desc: 'les cours du MIT en accès libre complet' },
|
|
||||||
{ nom: 'edX', url: 'https://www.edx.org/', desc: 'MOOCs de grandes universités' },
|
|
||||||
{ nom: 'Khan Academy', url: 'https://www.khanacademy.org/', desc: 'maths, sciences, économie — gratuit, du collège au supérieur' },
|
|
||||||
{ nom: 'Class Central', url: 'https://www.classcentral.com/', desc: 'moteur de recherche de cours en ligne' },
|
|
||||||
{ nom: 'WolframAlpha', url: 'https://www.wolframalpha.com/', desc: 'moteur de calcul et base de connaissances' },
|
|
||||||
{ nom: 'PhET', url: 'https://phet.colorado.edu/', desc: 'simulations interactives physique / chimie / maths' }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'ia',
|
|
||||||
titre: 'Intelligence artificielle gratuite',
|
|
||||||
liens: [
|
|
||||||
{ 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, 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' }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'francophone',
|
|
||||||
titre: 'Contenus francophones',
|
|
||||||
liens: [
|
|
||||||
{ nom: 'Gallica', url: 'https://gallica.bnf.fr/', desc: 'millions de documents numérisés de la BnF, domaine public' },
|
|
||||||
{ nom: 'Nos Livres', url: 'https://noslivres.net/', desc: 'classiques français du domaine public, formats propres' },
|
|
||||||
{ nom: 'Audiocite', url: 'https://www.audiocite.net/', desc: 'livres audio francophones du domaine public' },
|
|
||||||
{ nom: 'Littérature audio', url: 'https://www.litteratureaudio.com/', desc: 'autre grande bibliothèque audio francophone libre' },
|
|
||||||
{ nom: 'zone-ebook', url: 'https://zone-ebook.com/', desc: 'ebooks, magazines et journaux en français' },
|
|
||||||
{ nom: 'Dealabs', url: 'https://www.dealabs.com/', desc: 'bons plans et gratuités débusqués par la communauté' }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
useSeoMeta({
|
|
||||||
title: 'Outils — AEP',
|
|
||||||
description: 'Outils partagés entre architectes : simulateurs, open source, ressources libres FMHY, bifurcation post-études.'
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
/* ── Layout global ──────────────────────────────────────────── */
|
|
||||||
.outils-page {
|
|
||||||
max-width: 860px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 2rem 1.5rem 4rem;
|
|
||||||
color: var(--nav-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Header ─────────────────────────────────────────────────── */
|
|
||||||
.outils-header {
|
|
||||||
margin-bottom: 2.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.outils-header__inner {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 1.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.outils-header__icon-wrap {
|
|
||||||
width: 3rem;
|
|
||||||
height: 3rem;
|
|
||||||
border-radius: 10px;
|
|
||||||
background: var(--nav-bg-alt);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
flex-shrink: 0;
|
|
||||||
padding: 0.6rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.outils-header__icon {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
object-fit: contain;
|
|
||||||
opacity: 0.75;
|
|
||||||
}
|
|
||||||
|
|
||||||
.outils-header__title {
|
|
||||||
font-size: 1.75rem;
|
|
||||||
font-weight: 700;
|
|
||||||
margin: 0 0 0.5rem;
|
|
||||||
color: var(--nav-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.outils-header__intro {
|
|
||||||
font-size: 0.9rem;
|
|
||||||
color: var(--nav-text-muted);
|
|
||||||
line-height: 1.65;
|
|
||||||
margin: 0;
|
|
||||||
max-width: 70ch;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Sections ───────────────────────────────────────────────── */
|
|
||||||
.outils-main {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 3rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.outils-section {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.outils-section__title {
|
|
||||||
font-size: 1.15rem;
|
|
||||||
font-weight: 700;
|
|
||||||
margin: 0;
|
|
||||||
color: var(--nav-text);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
padding-bottom: 0.5rem;
|
|
||||||
border-bottom: 1.5px solid var(--nav-bg-alt);
|
|
||||||
}
|
|
||||||
|
|
||||||
.outils-section__subtitle {
|
|
||||||
font-size: 0.82rem;
|
|
||||||
color: var(--nav-text-muted);
|
|
||||||
margin: -0.5rem 0 0;
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
.outils-section__desc {
|
|
||||||
font-size: 0.88rem;
|
|
||||||
color: var(--nav-text-muted);
|
|
||||||
line-height: 1.65;
|
|
||||||
margin: 0;
|
|
||||||
max-width: 72ch;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Simulateurs ────────────────────────────────────────────── */
|
|
||||||
.outils-section--simulateurs {
|
|
||||||
gap: 1.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.simu-grid {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.simu-inspirations {
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.simu-inspirations__label {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-weight: 600;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
color: var(--nav-text-muted);
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Cards grid ─────────────────────────────────────────────── */
|
|
||||||
.outil-cards-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
|
||||||
gap: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Section FMHY ───────────────────────────────────────────── */
|
|
||||||
.outils-section--fmhy {
|
|
||||||
background: var(--nav-bg-alt);
|
|
||||||
border-radius: 14px;
|
|
||||||
padding: 1.75rem;
|
|
||||||
gap: 1.25rem;
|
|
||||||
margin: 0 -0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fmhy-tree-wrap {
|
|
||||||
background: var(--nav-surface);
|
|
||||||
border: 1px solid var(--nav-bg-alt);
|
|
||||||
border-radius: 10px;
|
|
||||||
padding: 1.25rem 1.5rem;
|
|
||||||
overflow-x: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fmhy-loading,
|
|
||||||
.fmhy-error {
|
|
||||||
font-size: 0.85rem;
|
|
||||||
color: var(--nav-text-muted);
|
|
||||||
padding: 0.5rem 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fmhy-error a {
|
|
||||||
color: var(--nav-primary-solid);
|
|
||||||
}
|
|
||||||
|
|
||||||
.fmhy-footer {
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fmhy-footer__link {
|
|
||||||
font-size: 0.82rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--nav-primary-solid);
|
|
||||||
text-decoration: none;
|
|
||||||
transition: opacity 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fmhy-footer__link:hover {
|
|
||||||
opacity: 0.75;
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Bifurcation ────────────────────────────────────────────── */
|
|
||||||
.bifurcation-block {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.6rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bifurcation-block__title {
|
|
||||||
font-size: 0.92rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--nav-text);
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bifurcation-block__desc {
|
|
||||||
font-size: 0.84rem;
|
|
||||||
color: var(--nav-text-muted);
|
|
||||||
margin: 0;
|
|
||||||
line-height: 1.55;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bifurcation-block--coalition {
|
|
||||||
background: var(--nav-bg-alt);
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 0.875rem 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Liste OFQA ─────────────────────────────────────────────── */
|
|
||||||
.ofqa-list {
|
|
||||||
list-style: none;
|
|
||||||
padding: 0;
|
|
||||||
margin: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ofqa-list__item {
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ofqa-list__link {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: baseline;
|
|
||||||
gap: 0.4rem;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
padding: 3px 6px;
|
|
||||||
border-radius: 5px;
|
|
||||||
font-size: 0.84rem;
|
|
||||||
text-decoration: none;
|
|
||||||
color: var(--nav-text);
|
|
||||||
transition: background 0.1s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ofqa-list__link:not(.ofqa-list__link--disabled):hover {
|
|
||||||
background: var(--nav-bg-alt);
|
|
||||||
color: var(--nav-primary-solid);
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ofqa-list__link--disabled {
|
|
||||||
color: var(--nav-text-muted);
|
|
||||||
cursor: default;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ofqa-list__ep {
|
|
||||||
font-family: 'JetBrains Mono', 'Courier New', monospace;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--nav-text-muted);
|
|
||||||
flex-shrink: 0;
|
|
||||||
min-width: 4.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ofqa-list__titre {
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ofqa-list__personnes {
|
|
||||||
color: var(--nav-text-muted);
|
|
||||||
font-size: 0.82rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ofqa-list__note {
|
|
||||||
color: var(--nav-text-muted);
|
|
||||||
font-size: 0.78rem;
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Section 5 placeholder ──────────────────────────────────── */
|
|
||||||
.outils-section--placeholder {
|
|
||||||
opacity: 0.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.placeholder-block {
|
|
||||||
border: 1.5px dashed var(--nav-bg-alt);
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 1.25rem 1.5rem;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.4rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.placeholder-block__badge {
|
|
||||||
font-size: 0.68rem;
|
|
||||||
font-weight: 600;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
color: var(--nav-text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.placeholder-block__title {
|
|
||||||
font-size: 0.95rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--nav-text);
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.placeholder-block__desc {
|
|
||||||
font-size: 0.84rem;
|
|
||||||
color: var(--nav-text-muted);
|
|
||||||
margin: 0;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Footer ─────────────────────────────────────────────────── */
|
|
||||||
.outils-footer {
|
|
||||||
padding-top: 1rem;
|
|
||||||
border-top: 1px solid var(--nav-bg-alt);
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.outils-footer__text {
|
|
||||||
font-size: 0.84rem;
|
|
||||||
color: var(--nav-text-muted);
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Mobile ─────────────────────────────────────────────────── */
|
|
||||||
@media (max-width: 640px) {
|
|
||||||
.outils-page {
|
|
||||||
padding: 1.25rem 1rem 4rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.outils-header__inner {
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.outils-header__title {
|
|
||||||
font-size: 1.4rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.outil-cards-grid {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.outils-section--fmhy {
|
|
||||||
padding: 1.25rem 1rem;
|
|
||||||
margin: 0 -0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Note FMHY détaillée (par usage) ────────────────────────── */
|
|
||||||
.fmhy-usage {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.35rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fmhy-usage__title {
|
|
||||||
font-size: 0.92rem;
|
|
||||||
font-weight: 650;
|
|
||||||
color: var(--nav-text);
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fmhy-usage__note {
|
|
||||||
font-size: 0.8rem;
|
|
||||||
color: var(--nav-text-muted);
|
|
||||||
font-style: italic;
|
|
||||||
margin: 0;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fmhy-usage__list {
|
|
||||||
list-style: none;
|
|
||||||
padding: 0;
|
|
||||||
margin: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fmhy-usage__item {
|
|
||||||
font-size: 0.84rem;
|
|
||||||
line-height: 1.5;
|
|
||||||
padding: 2px 6px;
|
|
||||||
border-radius: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fmhy-usage__item:hover {
|
|
||||||
background: var(--nav-surface);
|
|
||||||
}
|
|
||||||
|
|
||||||
.fmhy-usage__link {
|
|
||||||
color: var(--nav-primary-solid);
|
|
||||||
font-weight: 500;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fmhy-usage__link:hover {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fmhy-usage__desc {
|
|
||||||
color: var(--nav-text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.fmhy-warning {
|
|
||||||
background: var(--nav-surface);
|
|
||||||
border: 1px solid var(--nav-bg-alt);
|
|
||||||
border-left: 3px solid var(--nav-primary-solid);
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 0.875rem 1rem;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.35rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fmhy-warning__title {
|
|
||||||
font-size: 0.85rem;
|
|
||||||
font-weight: 650;
|
|
||||||
color: var(--nav-text);
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fmhy-warning__text {
|
|
||||||
font-size: 0.82rem;
|
|
||||||
color: var(--nav-text-muted);
|
|
||||||
line-height: 1.6;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fmhy-warning__text a {
|
|
||||||
color: var(--nav-primary-solid);
|
|
||||||
}
|
|
||||||
|
|
||||||
.fmhy-snapshot {
|
|
||||||
font-size: 0.78rem;
|
|
||||||
color: var(--nav-text-muted);
|
|
||||||
font-style: italic;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fmhy-snapshot a {
|
|
||||||
color: var(--nav-primary-solid);
|
|
||||||
}
|
|
||||||
|
|
||||||
.fmhy-tree-label {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-weight: 600;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
color: var(--nav-text-muted);
|
|
||||||
margin: 0.5rem 0 -0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fmhy-tree-wrap {
|
|
||||||
padding: 0.875rem 0.75rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
<template>
|
||||||
|
<div class="min-h-screen" style="background: var(--nav-bg);">
|
||||||
|
<div class="max-w-4xl mx-auto px-4 py-6">
|
||||||
|
|
||||||
|
<!-- ── Bouton retour carte (préserve filtres URL) ─── -->
|
||||||
|
<NuxtLink
|
||||||
|
:to="retourUrl"
|
||||||
|
class="inline-flex items-center gap-1.5 text-sm mb-6 rounded-lg px-3 py-1.5 transition-colors"
|
||||||
|
style="color: var(--nav-text); background: var(--nav-bg-alt);"
|
||||||
|
aria-label="Retour aux pratiques régénératives"
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<line x1="19" y1="12" x2="5" y2="12"/>
|
||||||
|
<polyline points="12 19 5 12 12 5"/>
|
||||||
|
</svg>
|
||||||
|
Retour aux pratiques régénératives
|
||||||
|
</NuxtLink>
|
||||||
|
|
||||||
|
<!-- ── Chargement ──────────────────────────────────── -->
|
||||||
|
<div v-if="pending" class="py-16 text-center text-sm" style="color: var(--nav-text-muted);">
|
||||||
|
Chargement de la fiche…
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Erreur ──────────────────────────────────────── -->
|
||||||
|
<div v-else-if="!pratique" class="py-16 text-center">
|
||||||
|
<p class="text-lg font-semibold mb-2" style="color: var(--nav-text);">Fiche introuvable</p>
|
||||||
|
<p class="text-sm" style="color: var(--nav-text-muted);">La pratique demandée n'existe pas ou a été supprimée.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Contenu ─────────────────────────────────────── -->
|
||||||
|
<template v-else>
|
||||||
|
|
||||||
|
<!-- Header fiche -->
|
||||||
|
<div class="mb-6">
|
||||||
|
<div class="flex items-start justify-between gap-4 mb-2">
|
||||||
|
<h1 class="text-2xl font-bold leading-tight" style="color: var(--nav-text);">{{ pratique.nom }}</h1>
|
||||||
|
<div class="flex items-center gap-2 shrink-0">
|
||||||
|
<span
|
||||||
|
class="px-2 py-1 rounded-full text-xs font-semibold uppercase tracking-wide"
|
||||||
|
style="background: var(--nav-bg-alt); color: var(--nav-text-muted);"
|
||||||
|
>{{ TYPES_ENTITE_LABELS[pratique.type] ?? pratique.type }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2 flex-wrap mb-3">
|
||||||
|
<span class="text-sm font-medium" style="color: var(--nav-text-muted);">
|
||||||
|
{{ PAYS_LABELS[pratique.pays] ?? pratique.pays }}
|
||||||
|
<template v-if="pratique.ville"> · {{ pratique.ville }}</template>
|
||||||
|
</span>
|
||||||
|
<span v-if="pratique.score" class="px-2 py-0.5 rounded text-xs" style="background: var(--nav-accent); color: var(--nav-text);">
|
||||||
|
Score {{ pratique.score }}/5
|
||||||
|
</span>
|
||||||
|
<a
|
||||||
|
v-if="pratique.url"
|
||||||
|
:href="pratique.url"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="text-sm underline"
|
||||||
|
style="color: var(--nav-primary-solid);"
|
||||||
|
>Site web →</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Description -->
|
||||||
|
<p v-if="pratique.description" class="text-sm leading-relaxed" style="color: var(--nav-text);">
|
||||||
|
{{ pratique.description }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Séparateur -->
|
||||||
|
<div class="mb-6" style="height: 1px; background: var(--nav-bg-alt);"></div>
|
||||||
|
|
||||||
|
<!-- Critères régénératifs -->
|
||||||
|
<div v-if="pratique.criteres?.length" class="mb-6">
|
||||||
|
<h2 class="text-xs font-bold uppercase tracking-wide mb-3" style="color: var(--nav-text-muted);">Critères régénératifs</h2>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<span
|
||||||
|
v-for="cId in pratique.criteres"
|
||||||
|
:key="cId"
|
||||||
|
class="px-3 py-1 rounded-full text-sm font-medium"
|
||||||
|
style="background: var(--nav-primary); color: var(--nav-text-on-primary);"
|
||||||
|
>
|
||||||
|
{{ CRITERES.find(c => c.id === cId)?.label ?? `Critère ${cId}` }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tags -->
|
||||||
|
<div v-if="pratique.tags?.length" class="mb-6">
|
||||||
|
<h2 class="text-xs font-bold uppercase tracking-wide mb-3" style="color: var(--nav-text-muted);">Tags</h2>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<span
|
||||||
|
v-for="tag in pratique.tags"
|
||||||
|
:key="tag"
|
||||||
|
class="px-2 py-0.5 rounded text-xs"
|
||||||
|
style="background: var(--nav-bg-alt); color: var(--nav-text-muted);"
|
||||||
|
>{{ tag }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Métadonnées -->
|
||||||
|
<div class="mb-6">
|
||||||
|
<h2 class="text-xs font-bold uppercase tracking-wide mb-3" style="color: var(--nav-text-muted);">Informations</h2>
|
||||||
|
<dl class="space-y-1.5">
|
||||||
|
<div v-if="pratique.passe" class="flex gap-2 text-sm">
|
||||||
|
<dt style="color: var(--nav-text-muted);">Passe :</dt>
|
||||||
|
<dd style="color: var(--nav-text);">{{ pratique.passe }}</dd>
|
||||||
|
</div>
|
||||||
|
<div v-if="pratique.source" class="flex gap-2 text-sm">
|
||||||
|
<dt style="color: var(--nav-text-muted);">Source :</dt>
|
||||||
|
<dd style="color: var(--nav-text);">{{ pratique.source }}</dd>
|
||||||
|
</div>
|
||||||
|
<div v-if="pratique.lat != null && pratique.lng != null" class="flex gap-2 text-sm">
|
||||||
|
<dt style="color: var(--nav-text-muted);">Coordonnées :</dt>
|
||||||
|
<dd style="color: var(--nav-text);">{{ pratique.lat }}, {{ pratique.lng }}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</template>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { Pratique } from '~/types/pratique'
|
||||||
|
import { CRITERES, TYPES_ENTITE_LABELS, PAYS_LABELS } from '~/types/pratique'
|
||||||
|
|
||||||
|
// ── Params & route ────────────────────────────────────────────────────
|
||||||
|
const route = useRoute()
|
||||||
|
const pratiqueId = route.params.id as string
|
||||||
|
|
||||||
|
// ── Retour carte — préserve les filtres via sessionStorage ────────────
|
||||||
|
const retourUrl = ref('/pratiques-regeneratives')
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
const stored = sessionStorage.getItem('pratiques_back_filters')
|
||||||
|
if (stored) {
|
||||||
|
retourUrl.value = `/pratiques-regeneratives?${stored}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Fetch toutes les pratiques et trouver la bonne ───────────────────
|
||||||
|
const { data, pending } = await useFetch<{ list: Pratique[]; source: string }>('/api/pratiques', {
|
||||||
|
key: `pratiques-all`,
|
||||||
|
})
|
||||||
|
|
||||||
|
const pratique = computed<Pratique | null>(() => {
|
||||||
|
const id = parseInt(pratiqueId, 10)
|
||||||
|
if (isNaN(id)) return null
|
||||||
|
return data.value?.list?.find(p => p.id === id) ?? null
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── SEO dynamiques ────────────────────────────────────────────────────
|
||||||
|
useHead({
|
||||||
|
title: computed(() =>
|
||||||
|
pratique.value ? `${pratique.value.nom} — Pratiques régénératives — AEP` : 'Pratique régénérative — AEP'
|
||||||
|
),
|
||||||
|
meta: [
|
||||||
|
{
|
||||||
|
name: 'description',
|
||||||
|
content: computed(() =>
|
||||||
|
pratique.value?.description?.substring(0, 160).trim() ?? 'Pratique régénérative — AEP'
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,469 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex h-full overflow-hidden" style="background: var(--nav-bg);">
|
||||||
|
|
||||||
|
<!-- ═══════════════════════════════════════ SIDEBAR DESKTOP (≥ 1024px) -->
|
||||||
|
<div class="hidden lg:flex w-80 shrink-0 flex-col overflow-hidden">
|
||||||
|
<PratiqueSidebar
|
||||||
|
:search="search"
|
||||||
|
:criteres="criteres"
|
||||||
|
:typesEntite="typesEntite"
|
||||||
|
:critereCount="critereCount"
|
||||||
|
:typeCount="typeCount"
|
||||||
|
:resultCount="filtered.length"
|
||||||
|
:pratiques="filtered"
|
||||||
|
:selectedId="selectedId"
|
||||||
|
:hasActiveFilters="hasActiveFilters"
|
||||||
|
:pending="pending"
|
||||||
|
@update:search="onSearch"
|
||||||
|
@update:criteres="onCriteres"
|
||||||
|
@update:typesEntite="onTypesEntite"
|
||||||
|
@select-pratique="onSelectPratique"
|
||||||
|
@hover-pratique="onHoverPratique"
|
||||||
|
@reset-filters="resetFilters"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ═══════════════════════════════════════ ZONE CENTRALE (carte) -->
|
||||||
|
<main class="flex-1 flex flex-col overflow-hidden relative">
|
||||||
|
|
||||||
|
<!-- ── VUE DESKTOP : Europe pleine largeur + DOM-TOM row en bas ── -->
|
||||||
|
<div class="hidden lg:flex lg:flex-1 lg:flex-col lg:overflow-hidden">
|
||||||
|
<!-- Carte Europe — pleine largeur -->
|
||||||
|
<div class="flex flex-col flex-1 overflow-hidden">
|
||||||
|
<div class="relative flex-1" style="min-height: 200px;">
|
||||||
|
<ClientOnly>
|
||||||
|
<EuropeMap
|
||||||
|
ref="europeMapRef"
|
||||||
|
:orgs="europeOrgs"
|
||||||
|
:selectedId="selectedId"
|
||||||
|
@select-org="onSelectPratique"
|
||||||
|
/>
|
||||||
|
<template #fallback>
|
||||||
|
<div
|
||||||
|
class="w-full h-full flex items-center justify-center"
|
||||||
|
style="background: var(--nav-bg-alt); color: var(--nav-text-muted);"
|
||||||
|
>
|
||||||
|
Chargement de la carte…
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</ClientOnly>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bandeau DOM-TOM — row horizontale pleine largeur, hauteur fixe -->
|
||||||
|
<div
|
||||||
|
class="shrink-0"
|
||||||
|
style="height: 140px; border-top: 1px solid var(--nav-bg-alt);"
|
||||||
|
>
|
||||||
|
<ClientOnly>
|
||||||
|
<OutremerMapPratiques
|
||||||
|
:orgs="outremerOrgs"
|
||||||
|
:selectedId="selectedId"
|
||||||
|
@select-org="onSelectPratique"
|
||||||
|
/>
|
||||||
|
<template #fallback>
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-center h-full text-sm"
|
||||||
|
style="color: var(--nav-text-muted);"
|
||||||
|
>
|
||||||
|
Chargement…
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</ClientOnly>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── VUE MOBILE : Onglets Europe/Outre-mer + carte pleine hauteur + sheet swipable ── -->
|
||||||
|
|
||||||
|
<!-- Onglets Europe / Outre-mer -->
|
||||||
|
<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 === 'europe'
|
||||||
|
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
|
||||||
|
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
|
||||||
|
@click="mobileMapView = 'europe'"
|
||||||
|
>Europe</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'"
|
||||||
|
>Outre-mer</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="lg:hidden flex-1 relative overflow-hidden">
|
||||||
|
|
||||||
|
<!-- Carte Europe -->
|
||||||
|
<div v-show="mobileMapView === 'europe'" class="absolute inset-0">
|
||||||
|
<ClientOnly>
|
||||||
|
<EuropeMap
|
||||||
|
ref="europeMapMobileRef"
|
||||||
|
:orgs="europeOrgs"
|
||||||
|
:selectedId="selectedId"
|
||||||
|
@select-org="onSelectPratiqueMobile"
|
||||||
|
/>
|
||||||
|
<template #fallback>
|
||||||
|
<div
|
||||||
|
class="w-full h-full flex items-center justify-center"
|
||||||
|
style="background: var(--nav-bg-alt); color: var(--nav-text-muted);"
|
||||||
|
>
|
||||||
|
Chargement de la carte…
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</ClientOnly>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Carte Outre-mer -->
|
||||||
|
<div v-show="mobileMapView === 'outremer'" class="absolute inset-0 overflow-y-auto" style="background: var(--nav-bg);">
|
||||||
|
<ClientOnly>
|
||||||
|
<OutremerMapPratiques
|
||||||
|
:orgs="outremerOrgs"
|
||||||
|
:selectedId="selectedId"
|
||||||
|
@select-org="onSelectPratiqueMobile"
|
||||||
|
/>
|
||||||
|
<template #fallback>
|
||||||
|
<div class="flex items-center justify-center h-48" style="color: var(--nav-text-muted);">
|
||||||
|
Chargement…
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</ClientOnly>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bottom sheet swipable (Europe et Outre-mer) -->
|
||||||
|
<ClientOnly>
|
||||||
|
<MobileSheet :resultCount="filtered.length" :pending="pending">
|
||||||
|
<!-- Barre recherche -->
|
||||||
|
<div class="px-3 pt-2 pb-2" style="border-bottom: 1px solid var(--nav-bg-alt);">
|
||||||
|
<label class="mobile-search-label" aria-label="Rechercher une pratique">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="color: var(--nav-text-muted); flex-shrink: 0;">
|
||||||
|
<circle cx="11" cy="11" r="8"/>
|
||||||
|
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
|
||||||
|
</svg>
|
||||||
|
<input
|
||||||
|
v-model="mobileSearch"
|
||||||
|
type="search"
|
||||||
|
placeholder="Rechercher…"
|
||||||
|
class="mobile-search-input"
|
||||||
|
autocomplete="off"
|
||||||
|
@input="onSearch(mobileSearch)"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
v-if="mobileSearch"
|
||||||
|
type="button"
|
||||||
|
class="mobile-search-clear"
|
||||||
|
aria-label="Effacer"
|
||||||
|
@click.stop="mobileSearch = ''; onSearch('')"
|
||||||
|
>
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<!-- Filtres CRITÈRES — chips -->
|
||||||
|
<div class="mt-2">
|
||||||
|
<span class="text-xs font-bold uppercase tracking-wide block mb-1" style="color: var(--nav-text-muted);">CRITÈRES</span>
|
||||||
|
<div class="flex flex-wrap gap-1">
|
||||||
|
<span
|
||||||
|
v-for="c in CRITERES"
|
||||||
|
:key="c.id"
|
||||||
|
class="cursor-pointer px-2 py-0.5 rounded-full text-xs transition-all"
|
||||||
|
:style="criteres.includes(c.id)
|
||||||
|
? 'background: var(--nav-primary); color: var(--nav-text-on-primary); font-weight: 600;'
|
||||||
|
: 'background: var(--nav-bg-alt); color: var(--nav-text-muted);'"
|
||||||
|
@click="toggleCritere(c.id)"
|
||||||
|
>{{ c.label }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filtres TYPE — chips -->
|
||||||
|
<div class="mt-2">
|
||||||
|
<span class="text-xs font-bold uppercase tracking-wide block mb-1" style="color: var(--nav-text-muted);">TYPE</span>
|
||||||
|
<div class="flex flex-wrap gap-1">
|
||||||
|
<span
|
||||||
|
v-for="t in TYPES_ENTITE"
|
||||||
|
:key="t"
|
||||||
|
class="cursor-pointer px-2 py-0.5 rounded-full text-xs transition-all"
|
||||||
|
:style="typesEntite.includes(t)
|
||||||
|
? 'background: var(--nav-primary); color: var(--nav-text-on-primary); font-weight: 600;'
|
||||||
|
: 'background: var(--nav-bg-alt); color: var(--nav-text-muted);'"
|
||||||
|
@click="toggleType(t)"
|
||||||
|
>{{ TYPES_ENTITE_LABELS[t] ?? t }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
v-if="hasActiveFilters"
|
||||||
|
@click="resetFilters"
|
||||||
|
class="mt-2 text-xs"
|
||||||
|
style="color: var(--nav-text-muted); text-decoration: underline;"
|
||||||
|
>Effacer les filtres</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Compteur + Liste fiches -->
|
||||||
|
<div class="px-3 py-2">
|
||||||
|
<div class="text-xs font-bold uppercase tracking-wide mb-2" style="color: var(--nav-text-muted);">
|
||||||
|
{{ filtered.length }} résultat{{ filtered.length > 1 ? 's' : '' }}
|
||||||
|
</div>
|
||||||
|
<div v-if="pending" class="flex items-center justify-center py-8" style="color: var(--nav-text-muted);">
|
||||||
|
Chargement des fiches…
|
||||||
|
</div>
|
||||||
|
<div v-else-if="filtered.length === 0" class="text-center py-8">
|
||||||
|
<p class="text-sm mb-2" style="color: var(--nav-text-muted);">Aucun résultat pour ces filtres.</p>
|
||||||
|
<button @click="resetFilters" class="text-sm underline" style="color: var(--nav-primary-solid);">
|
||||||
|
Effacer les filtres
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div
|
||||||
|
v-for="pratique in filtered"
|
||||||
|
:key="pratique.id"
|
||||||
|
class="block rounded-lg p-3 transition-all cursor-pointer"
|
||||||
|
:style="selectedId === pratique.id
|
||||||
|
? 'background: var(--nav-bg-alt); border-left: 3px solid var(--nav-accent);'
|
||||||
|
: 'background: var(--nav-surface); border-left: 3px solid transparent;'"
|
||||||
|
@click="onSelectPratiqueMobile(pratique.id)"
|
||||||
|
>
|
||||||
|
<div class="flex items-start justify-between gap-2">
|
||||||
|
<span class="font-semibold text-sm leading-snug" style="color: var(--nav-text);">{{ pratique.nom }}</span>
|
||||||
|
<span
|
||||||
|
v-if="pratique.pays"
|
||||||
|
class="shrink-0 px-2 py-0.5 rounded-full text-xs font-medium"
|
||||||
|
style="background: var(--nav-bg-alt); color: var(--nav-text-muted);"
|
||||||
|
>{{ pratique.pays }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="pratique.criteres?.length" class="mt-1 flex flex-wrap gap-1">
|
||||||
|
<span
|
||||||
|
v-for="cId in pratique.criteres.slice(0, 3)"
|
||||||
|
:key="cId"
|
||||||
|
class="px-1.5 py-0.5 rounded text-xs"
|
||||||
|
style="background: var(--nav-bg-alt); color: var(--nav-text-muted);"
|
||||||
|
>{{ CRITERES.find(c => c.id === cId)?.label }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="pratique.ville" class="mt-1 text-xs" style="color: var(--nav-text-muted);">
|
||||||
|
{{ pratique.ville }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</MobileSheet>
|
||||||
|
</ClientOnly>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- ═══════════════════════════════════════ BOUTON CHATBOT FLOTTANT (mobile) — désactivé V1 -->
|
||||||
|
<button
|
||||||
|
v-if="false"
|
||||||
|
class="lg:hidden fixed bottom-6 right-4 z-[1000] flex items-center gap-2 px-4 rounded-full shadow-lg"
|
||||||
|
style="
|
||||||
|
height: 48px;
|
||||||
|
background: var(--nav-primary);
|
||||||
|
opacity: 0.5;
|
||||||
|
color: var(--nav-text-on-primary);
|
||||||
|
box-shadow: 0 4px 16px rgba(26,34,56,0.25);
|
||||||
|
font-family: var(--nav-font);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
"
|
||||||
|
aria-label="Chatbot (bientôt disponible)"
|
||||||
|
disabled
|
||||||
|
>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
|
||||||
|
</svg>
|
||||||
|
<span>Chatbot</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { Pratique } from '~/types/pratique'
|
||||||
|
import { CRITERES, TYPES_ENTITE, TYPES_ENTITE_LABELS, EUROPE_CODES, OUTREMER_CODES } from '~/types/pratique'
|
||||||
|
|
||||||
|
// ── URL query params sync ─────────────────────────────────────────────────
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const search = ref<string>((route.query.q as string) ?? '')
|
||||||
|
const criteres = ref<number[]>(
|
||||||
|
route.query.criteres
|
||||||
|
? (route.query.criteres as string).split(',').map(Number).filter(Boolean)
|
||||||
|
: []
|
||||||
|
)
|
||||||
|
const typesEntite = ref<string[]>(
|
||||||
|
route.query.types
|
||||||
|
? (route.query.types as string).split(',').filter(Boolean)
|
||||||
|
: []
|
||||||
|
)
|
||||||
|
const pays = ref<string[]>(
|
||||||
|
route.query.pays
|
||||||
|
? (route.query.pays as string).split(',').filter(Boolean)
|
||||||
|
: []
|
||||||
|
)
|
||||||
|
|
||||||
|
const selectedId = ref<number | null>(null)
|
||||||
|
const mobileMapView = ref<'europe' | 'outremer'>('europe')
|
||||||
|
|
||||||
|
// Refs vers les instances EuropeMap
|
||||||
|
const europeMapRef = ref<any>(null)
|
||||||
|
const europeMapMobileRef = ref<any>(null)
|
||||||
|
|
||||||
|
// Ref locale barre de recherche mobile
|
||||||
|
const mobileSearch = ref<string>((route.query.q as string) ?? '')
|
||||||
|
|
||||||
|
// Sync URL <-> état filtres
|
||||||
|
function syncUrl() {
|
||||||
|
const q: Record<string, string> = {}
|
||||||
|
if (search.value) q.q = search.value
|
||||||
|
if (criteres.value.length) q.criteres = criteres.value.join(',')
|
||||||
|
if (typesEntite.value.length) q.types = typesEntite.value.join(',')
|
||||||
|
if (pays.value.length) q.pays = pays.value.join(',')
|
||||||
|
router.replace({ query: Object.keys(q).length ? q : undefined })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sauvegarde filtres pour bouton retour des fiches
|
||||||
|
function storeFiltersForBack() {
|
||||||
|
if (typeof window === 'undefined') return
|
||||||
|
const q: Record<string, string> = {}
|
||||||
|
if (search.value) q.q = search.value
|
||||||
|
if (criteres.value.length) q.criteres = criteres.value.join(',')
|
||||||
|
if (typesEntite.value.length) q.types = typesEntite.value.join(',')
|
||||||
|
if (pays.value.length) q.pays = pays.value.join(',')
|
||||||
|
const qs = new URLSearchParams(q).toString()
|
||||||
|
sessionStorage.setItem('pratiques_back_filters', qs)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSearch(v: string) { search.value = v; syncUrl(); storeFiltersForBack() }
|
||||||
|
function onCriteres(v: number[]) { criteres.value = v; syncUrl(); storeFiltersForBack() }
|
||||||
|
function onTypesEntite(v: string[]) { typesEntite.value = v; syncUrl(); storeFiltersForBack() }
|
||||||
|
function onPays(v: string[]) { pays.value = v; syncUrl(); storeFiltersForBack() }
|
||||||
|
|
||||||
|
function onSelectPratique(id: number) {
|
||||||
|
selectedId.value = selectedId.value === id ? null : id
|
||||||
|
// Desktop : naviguer vers la fiche
|
||||||
|
if (typeof window !== 'undefined' && window.innerWidth >= 1024) {
|
||||||
|
storeFiltersForBack()
|
||||||
|
router.push(`/pratique/${id}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSelectPratiqueMobile(id: number) {
|
||||||
|
selectedId.value = id
|
||||||
|
storeFiltersForBack()
|
||||||
|
router.push(`/pratique/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onHoverPratique(id: number | null) {
|
||||||
|
if (id !== null) selectedId.value = id
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasActiveFilters = computed(() =>
|
||||||
|
!!search.value || criteres.value.length > 0 || typesEntite.value.length > 0 || pays.value.length > 0
|
||||||
|
)
|
||||||
|
|
||||||
|
function resetFilters() {
|
||||||
|
search.value = ''
|
||||||
|
criteres.value = []
|
||||||
|
typesEntite.value = []
|
||||||
|
pays.value = []
|
||||||
|
router.replace({ query: undefined })
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleCritere(id: number) {
|
||||||
|
if (criteres.value.includes(id)) {
|
||||||
|
onCriteres(criteres.value.filter(v => v !== id))
|
||||||
|
} else {
|
||||||
|
onCriteres([...criteres.value, id])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleType(t: string) {
|
||||||
|
if (typesEntite.value.includes(t)) {
|
||||||
|
onTypesEntite(typesEntite.value.filter(v => v !== t))
|
||||||
|
} else {
|
||||||
|
onTypesEntite([...typesEntite.value, t])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync recherche depuis URL ?q=
|
||||||
|
watch(() => route.query.q, (v) => {
|
||||||
|
search.value = (v as string) ?? ''
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Données ───────────────────────────────────────────────────────────────
|
||||||
|
const { data, pending, error: fetchError } = await useFetch<{ list: Pratique[]; source: string }>('/api/pratiques')
|
||||||
|
|
||||||
|
const pratiques = computed<Pratique[]>(() => data.value?.list ?? [])
|
||||||
|
|
||||||
|
// ── Filtrage côté client ──────────────────────────────────────────────────
|
||||||
|
const filtered = computed<Pratique[]>(() => {
|
||||||
|
let result = pratiques.value
|
||||||
|
|
||||||
|
if (search.value.trim()) {
|
||||||
|
const q = search.value.toLowerCase()
|
||||||
|
result = result.filter(
|
||||||
|
(o) =>
|
||||||
|
o.nom?.toLowerCase().includes(q) ||
|
||||||
|
o.ville?.toLowerCase().includes(q) ||
|
||||||
|
o.description?.toLowerCase().includes(q)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (criteres.value.length) {
|
||||||
|
result = result.filter((o) =>
|
||||||
|
criteres.value.some((cId) => o.criteres?.includes(cId))
|
||||||
|
)
|
||||||
|
// Tri par score pondéré : priorité au premier critère cliqué
|
||||||
|
const n = criteres.value.length
|
||||||
|
const score = (o: Pratique) =>
|
||||||
|
criteres.value.reduce((s, cId, i) => {
|
||||||
|
return s + (o.criteres?.includes(cId) ? (n - i) : 0)
|
||||||
|
}, 0)
|
||||||
|
result = [...result].sort((a, b) => score(b) - score(a))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typesEntite.value.length) {
|
||||||
|
result = result.filter((o) => o.type && typesEntite.value.includes(o.type))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pays.value.length) {
|
||||||
|
result = result.filter((o) => o.pays && pays.value.includes(o.pays))
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
|
||||||
|
// Séparation Europe / Outre-mer
|
||||||
|
const europeOrgs = computed<Pratique[]>(() =>
|
||||||
|
filtered.value.filter(o => !o.pays || (EUROPE_CODES as readonly string[]).includes(o.pays))
|
||||||
|
)
|
||||||
|
|
||||||
|
const outremerOrgs = computed<Pratique[]>(() =>
|
||||||
|
filtered.value.filter(o => o.pays && (OUTREMER_CODES as readonly string[]).includes(o.pays))
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Compteurs ─────────────────────────────────────────────────────────────
|
||||||
|
const critereCount = computed<Record<number, number>>(() => {
|
||||||
|
const counts: Record<number, number> = {}
|
||||||
|
CRITERES.forEach(c => { counts[c.id] = 0 })
|
||||||
|
pratiques.value.forEach(o => {
|
||||||
|
o.criteres?.forEach(cId => { counts[cId] = (counts[cId] ?? 0) + 1 })
|
||||||
|
})
|
||||||
|
return counts
|
||||||
|
})
|
||||||
|
|
||||||
|
const typeCount = computed<Record<string, number>>(() => {
|
||||||
|
const counts: Record<string, number> = {}
|
||||||
|
TYPES_ENTITE.forEach(t => { counts[t] = 0 })
|
||||||
|
pratiques.value.forEach(o => {
|
||||||
|
if (o.type) counts[o.type] = (counts[o.type] ?? 0) + 1
|
||||||
|
})
|
||||||
|
return counts
|
||||||
|
})
|
||||||
|
|
||||||
|
useHead({ title: 'AEP — Pratiques régénératives en Europe' })
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,833 @@
|
|||||||
|
<template>
|
||||||
|
<div class="contribuer-page">
|
||||||
|
<div class="contribuer-inner">
|
||||||
|
<!-- Retour -->
|
||||||
|
<NuxtLink to="/pratiques-regeneratives" class="back-link">
|
||||||
|
← Retour à la carte
|
||||||
|
</NuxtLink>
|
||||||
|
|
||||||
|
<!-- En-tête -->
|
||||||
|
<div class="contribuer-header">
|
||||||
|
<h1>Proposer une pratique</h1>
|
||||||
|
<p class="contribuer-subtitle">
|
||||||
|
Tu connais une agence, un collectif ou un réseau qui incarne l'architecture régénérative ?
|
||||||
|
Soumets-le ici — Jules valide manuellement les nouvelles entrées.
|
||||||
|
</p>
|
||||||
|
<p class="contribuer-hint">
|
||||||
|
Si tu n'as pas le temps de tout remplir, laisse-nous juste le lien.
|
||||||
|
Mais une description de ta main, c'est toujours plus vivant.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Message succès -->
|
||||||
|
<div v-if="success" class="success-block" role="status" aria-live="polite">
|
||||||
|
<div class="success-icon">✓</div>
|
||||||
|
<h2>Merci !</h2>
|
||||||
|
<p>Ta proposition est en attente de modération.</p>
|
||||||
|
<p class="success-detail">
|
||||||
|
Jules valide manuellement chaque entrée avant publication.
|
||||||
|
</p>
|
||||||
|
<button type="button" class="btn-secondary" @click="reset">
|
||||||
|
Proposer une autre pratique
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Formulaire -->
|
||||||
|
<form v-else @submit.prevent="submit" class="contribuer-form" novalidate>
|
||||||
|
|
||||||
|
<!-- Nom -->
|
||||||
|
<div class="field-group" :class="{ 'field-error': errors.nom }">
|
||||||
|
<label for="nom">Nom de l'organisation <span class="required">*</span></label>
|
||||||
|
<input
|
||||||
|
id="nom"
|
||||||
|
v-model="form.nom"
|
||||||
|
type="text"
|
||||||
|
placeholder="Ex : Lacaton & Vassal, Plateau Urbain..."
|
||||||
|
autocomplete="organization"
|
||||||
|
@blur="validateField('nom')"
|
||||||
|
/>
|
||||||
|
<span v-if="errors.nom" class="error-msg" role="alert">{{ errors.nom }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- URL -->
|
||||||
|
<div class="field-group" :class="{ 'field-error': errors.url }">
|
||||||
|
<label for="url">
|
||||||
|
Site web
|
||||||
|
<span class="label-hint">(optionnel — recommandé)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="url"
|
||||||
|
v-model="form.url"
|
||||||
|
type="url"
|
||||||
|
placeholder="https://..."
|
||||||
|
@blur="validateField('url')"
|
||||||
|
/>
|
||||||
|
<span v-if="errors.url" class="error-msg" role="alert">{{ errors.url }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Description -->
|
||||||
|
<div class="field-group" :class="{ 'field-error': errors.description_user }">
|
||||||
|
<label for="description_user">
|
||||||
|
Description courte <span class="required">*</span>
|
||||||
|
<span class="label-hint">(50 à 500 caractères)</span>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="description_user"
|
||||||
|
v-model="form.description_user"
|
||||||
|
rows="4"
|
||||||
|
placeholder="Décris cette pratique : approche, matériaux, posture, ce qui la rend régénérative..."
|
||||||
|
@blur="validateField('description_user')"
|
||||||
|
/>
|
||||||
|
<div class="field-meta">
|
||||||
|
<span v-if="errors.description_user" class="error-msg" role="alert">
|
||||||
|
{{ errors.description_user }}
|
||||||
|
</span>
|
||||||
|
<span v-else class="char-count" :class="{ 'char-warn': form.description_user.length > 450 }">
|
||||||
|
{{ form.description_user.length }}/500
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Critères régénératifs -->
|
||||||
|
<div class="field-group" :class="{ 'field-error': errors.criteres }">
|
||||||
|
<fieldset>
|
||||||
|
<legend>
|
||||||
|
Critères régénératifs <span class="required">*</span>
|
||||||
|
<span class="label-hint">(3 minimum, 8 maximum)</span>
|
||||||
|
</legend>
|
||||||
|
<div class="checkbox-grid">
|
||||||
|
<label
|
||||||
|
v-for="c in CRITERES"
|
||||||
|
:key="c.id"
|
||||||
|
class="checkbox-label"
|
||||||
|
:class="{
|
||||||
|
active: form.criteres.includes(c.id),
|
||||||
|
disabled: !form.criteres.includes(c.id) && form.criteres.length >= 8,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
:value="c.id"
|
||||||
|
:checked="form.criteres.includes(c.id)"
|
||||||
|
:disabled="!form.criteres.includes(c.id) && form.criteres.length >= 8"
|
||||||
|
@change="toggleCritere(c.id)"
|
||||||
|
/>
|
||||||
|
{{ c.label }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
<span v-if="errors.criteres" class="error-msg" role="alert">{{ errors.criteres }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Type d'entité -->
|
||||||
|
<div class="field-group" :class="{ 'field-error': errors.type }">
|
||||||
|
<fieldset>
|
||||||
|
<legend>
|
||||||
|
Type d'entité <span class="required">*</span>
|
||||||
|
</legend>
|
||||||
|
<div class="radio-group">
|
||||||
|
<label
|
||||||
|
v-for="t in TYPES_ENTITE"
|
||||||
|
:key="t"
|
||||||
|
class="radio-label"
|
||||||
|
:class="{ active: form.type === t }"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
:value="t"
|
||||||
|
v-model="form.type"
|
||||||
|
name="type"
|
||||||
|
@change="validateField('type')"
|
||||||
|
/>
|
||||||
|
{{ TYPES_ENTITE_LABELS[t] }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
<span v-if="errors.type" class="error-msg" role="alert">{{ errors.type }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pays -->
|
||||||
|
<div class="field-group" :class="{ 'field-error': errors.pays }">
|
||||||
|
<label for="pays">
|
||||||
|
Pays <span class="required">*</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="pays"
|
||||||
|
v-model="form.pays"
|
||||||
|
@change="validateField('pays')"
|
||||||
|
>
|
||||||
|
<option value="" disabled>Sélectionne un pays...</option>
|
||||||
|
<optgroup label="Europe">
|
||||||
|
<option v-for="code in EUROPE_CODES" :key="code" :value="code">
|
||||||
|
{{ PAYS_LABELS[code] }}
|
||||||
|
</option>
|
||||||
|
</optgroup>
|
||||||
|
<optgroup label="DOM-TOM">
|
||||||
|
<option v-for="code in OUTREMER_CODES" :key="code" :value="code">
|
||||||
|
{{ PAYS_LABELS[code] }}
|
||||||
|
</option>
|
||||||
|
</optgroup>
|
||||||
|
<optgroup label="Autre">
|
||||||
|
<option value="AUTRE">Autre pays...</option>
|
||||||
|
</optgroup>
|
||||||
|
</select>
|
||||||
|
<span v-if="errors.pays" class="error-msg" role="alert">{{ errors.pays }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pays autre (conditionnel) -->
|
||||||
|
<div v-if="form.pays === 'AUTRE'" class="field-group" :class="{ 'field-error': errors.pays_autre }">
|
||||||
|
<label for="pays_autre">Précise le pays</label>
|
||||||
|
<input
|
||||||
|
id="pays_autre"
|
||||||
|
v-model="form.pays_autre"
|
||||||
|
type="text"
|
||||||
|
placeholder="Ex : Maroc, Brésil..."
|
||||||
|
maxlength="50"
|
||||||
|
/>
|
||||||
|
<span v-if="errors.pays_autre" class="error-msg" role="alert">{{ errors.pays_autre }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Ville -->
|
||||||
|
<div class="field-group" :class="{ 'field-error': errors.ville }">
|
||||||
|
<label for="ville">
|
||||||
|
Ville principale
|
||||||
|
<span class="label-hint">(optionnel)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="ville"
|
||||||
|
v-model="form.ville"
|
||||||
|
type="text"
|
||||||
|
placeholder="Ex : Paris, Bordeaux, Bruxelles..."
|
||||||
|
/>
|
||||||
|
<span v-if="errors.ville" class="error-msg" role="alert">{{ errors.ville }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tags -->
|
||||||
|
<div class="field-group" :class="{ 'field-error': errors.tags }">
|
||||||
|
<label for="tags">
|
||||||
|
Tags
|
||||||
|
<span class="label-hint">(optionnel — 3 à 6 mots-clés, séparés par des virgules)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="tags"
|
||||||
|
v-model="tagsInput"
|
||||||
|
type="text"
|
||||||
|
placeholder="Ex : biosourcé, réhabilitation, circuit-court"
|
||||||
|
@blur="parseTags"
|
||||||
|
/>
|
||||||
|
<span v-if="errors.tags" class="error-msg" role="alert">{{ errors.tags }}</span>
|
||||||
|
<div v-if="form.tags && form.tags.length" class="tags-preview">
|
||||||
|
<span v-for="tag in form.tags" :key="tag" class="tag-chip">{{ tag }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Email -->
|
||||||
|
<div class="field-group" :class="{ 'field-error': errors.submitted_by_email }">
|
||||||
|
<label for="submitted_by_email">
|
||||||
|
Ton email
|
||||||
|
<span class="label-hint">(optionnel — pour le suivi)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="submitted_by_email"
|
||||||
|
v-model="form.submitted_by_email"
|
||||||
|
type="email"
|
||||||
|
placeholder="ton@email.fr"
|
||||||
|
autocomplete="email"
|
||||||
|
@blur="validateField('submitted_by_email')"
|
||||||
|
/>
|
||||||
|
<span v-if="errors.submitted_by_email" class="error-msg" role="alert">
|
||||||
|
{{ errors.submitted_by_email }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Erreur globale -->
|
||||||
|
<div v-if="serverError" class="server-error" role="alert">
|
||||||
|
<strong>Erreur :</strong> {{ serverError }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="form-actions">
|
||||||
|
<NuxtLink to="/pratiques-regeneratives" class="btn-secondary">Annuler</NuxtLink>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="btn-primary"
|
||||||
|
:disabled="submitting"
|
||||||
|
>
|
||||||
|
{{ submitting ? 'Envoi en cours...' : 'Proposer la pratique →' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="form-note">
|
||||||
|
Ta proposition sera examinée par Jules avant publication.
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { CRITERES, TYPES_ENTITE, TYPES_ENTITE_LABELS, EUROPE_CODES, OUTREMER_CODES, PAYS_LABELS } from '~/types/pratique'
|
||||||
|
|
||||||
|
// ── Schéma Zod (côté client — miroir du serveur) ──────────────────────────────
|
||||||
|
|
||||||
|
const PratiqueSubmitSchema = z.object({
|
||||||
|
nom: z.string().min(3, 'Minimum 3 caractères').max(150, 'Maximum 150 caractères').trim(),
|
||||||
|
url: z.string().url('URL invalide (commencer par https://)').optional().or(z.literal('')),
|
||||||
|
description_user: z.string().min(50, 'Minimum 50 caractères').max(500, 'Maximum 500 caractères').trim(),
|
||||||
|
criteres: z
|
||||||
|
.array(z.number().int().min(1).max(8))
|
||||||
|
.min(3, 'Sélectionne au moins 3 critères')
|
||||||
|
.max(8, 'Maximum 8 critères'),
|
||||||
|
pays: z.string().length(2, 'Sélectionne un pays').or(z.literal('AUTRE')),
|
||||||
|
pays_autre: z.string().max(50).optional(),
|
||||||
|
ville: z.string().max(100).optional(),
|
||||||
|
type: z.enum(TYPES_ENTITE, { errorMap: () => ({ message: 'Sélectionne un type d\'entité' }) }),
|
||||||
|
tags: z.array(z.string().max(30)).max(6).optional(),
|
||||||
|
submitted_by_email: z.string().email('Email invalide').optional().or(z.literal('')),
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── État du formulaire ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
nom: '',
|
||||||
|
url: '',
|
||||||
|
description_user: '',
|
||||||
|
criteres: [] as number[],
|
||||||
|
pays: '' as string,
|
||||||
|
pays_autre: '',
|
||||||
|
ville: '',
|
||||||
|
type: '' as typeof TYPES_ENTITE[number] | '',
|
||||||
|
tags: [] as string[],
|
||||||
|
submitted_by_email: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const tagsInput = ref('')
|
||||||
|
const errors = reactive<Record<string, string>>({})
|
||||||
|
const submitting = ref(false)
|
||||||
|
const success = ref(false)
|
||||||
|
const serverError = ref('')
|
||||||
|
|
||||||
|
// ── Validation champ par champ ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function validateField(field: string) {
|
||||||
|
const partial = PratiqueSubmitSchema.partial()
|
||||||
|
const result = partial.safeParse({ [field]: (form as any)[field] })
|
||||||
|
if (!result.success) {
|
||||||
|
const fieldErrors = result.error.flatten().fieldErrors
|
||||||
|
errors[field] = fieldErrors[field]?.[0] ?? ''
|
||||||
|
} else {
|
||||||
|
delete errors[field]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateAll(): boolean {
|
||||||
|
const result = PratiqueSubmitSchema.safeParse(form)
|
||||||
|
if (!result.success) {
|
||||||
|
const flat = result.error.flatten().fieldErrors
|
||||||
|
Object.assign(errors, Object.fromEntries(
|
||||||
|
Object.entries(flat).map(([k, v]) => [k, v?.[0] ?? ''])
|
||||||
|
))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
Object.keys(errors).forEach(k => delete errors[k])
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Gestion critères ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function toggleCritere(id: number) {
|
||||||
|
const idx = form.criteres.indexOf(id)
|
||||||
|
if (idx >= 0) {
|
||||||
|
form.criteres.splice(idx, 1)
|
||||||
|
} else if (form.criteres.length < 8) {
|
||||||
|
form.criteres.push(id)
|
||||||
|
}
|
||||||
|
validateField('criteres')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Gestion tags ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function parseTags() {
|
||||||
|
const raw = tagsInput.value
|
||||||
|
.split(',')
|
||||||
|
.map(t => t.trim().toLowerCase())
|
||||||
|
.filter(t => t.length > 0 && t.length <= 30)
|
||||||
|
.slice(0, 6)
|
||||||
|
form.tags = raw
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Soumission ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
serverError.value = ''
|
||||||
|
parseTags()
|
||||||
|
|
||||||
|
if (!validateAll()) {
|
||||||
|
await nextTick()
|
||||||
|
const firstError = document.querySelector('.field-error')
|
||||||
|
firstError?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
submitting.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
await $fetch('/api/submit-pratique', {
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
nom: form.nom,
|
||||||
|
url: form.url || undefined,
|
||||||
|
description_user: form.description_user,
|
||||||
|
criteres: form.criteres,
|
||||||
|
pays: form.pays,
|
||||||
|
pays_autre: form.pays_autre || undefined,
|
||||||
|
ville: form.ville || undefined,
|
||||||
|
type: form.type,
|
||||||
|
tags: form.tags.length ? form.tags : undefined,
|
||||||
|
submitted_by_email: form.submitted_by_email || undefined,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
success.value = true
|
||||||
|
} catch (e: any) {
|
||||||
|
const status = e?.status ?? e?.statusCode
|
||||||
|
if (status === 429) {
|
||||||
|
serverError.value = 'Tu as déjà soumis 3 pratiques aujourd\'hui. Réessaie demain.'
|
||||||
|
} else if (status === 422 && e?.data) {
|
||||||
|
const fieldErrors = e.data
|
||||||
|
Object.entries(fieldErrors).forEach(([k, v]) => {
|
||||||
|
errors[k] = Array.isArray(v) ? v[0] : String(v)
|
||||||
|
})
|
||||||
|
serverError.value = 'Certains champs sont invalides — vérifie les erreurs ci-dessus.'
|
||||||
|
} else {
|
||||||
|
serverError.value = 'Une erreur s\'est produite. Réessaie dans quelques instants.'
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
Object.assign(form, {
|
||||||
|
nom: '', url: '', description_user: '', criteres: [],
|
||||||
|
pays: '', pays_autre: '', ville: '', type: '', tags: [], submitted_by_email: '',
|
||||||
|
})
|
||||||
|
tagsInput.value = ''
|
||||||
|
Object.keys(errors).forEach(k => delete errors[k])
|
||||||
|
success.value = false
|
||||||
|
serverError.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Meta ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
useHead({ title: 'Proposer une pratique — AEP' })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* ── Layout ─────────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.contribuer-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: var(--nav-bg);
|
||||||
|
padding: 1.5rem 1rem 4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contribuer-inner {
|
||||||
|
max-width: 640px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Retour ──────────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.back-link {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--nav-primary-solid);
|
||||||
|
opacity: 0.7;
|
||||||
|
text-decoration: none;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-link:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── En-tête ─────────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.contribuer-header {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contribuer-header h1 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--nav-text);
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contribuer-subtitle {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--nav-text-muted);
|
||||||
|
line-height: 1.5;
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contribuer-hint {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--nav-text-muted);
|
||||||
|
opacity: 0.75;
|
||||||
|
line-height: 1.5;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Succès ──────────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.success-block {
|
||||||
|
background: var(--nav-surface);
|
||||||
|
border: 1px solid rgba(26, 34, 56, 0.15);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 2rem 1.5rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-icon {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
background: rgba(26, 34, 56, 0.1);
|
||||||
|
color: var(--nav-text);
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0 auto 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-block h2 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--nav-text);
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-block p {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--nav-text-muted);
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-detail {
|
||||||
|
font-size: 0.85rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Formulaire ──────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.contribuer-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Champ générique ─────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.field-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-group label,
|
||||||
|
.field-group legend {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--nav-text);
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-group fieldset {
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.required {
|
||||||
|
color: #c0392b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label-hint {
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--nav-text-muted);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
margin-left: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-group input[type="text"],
|
||||||
|
.field-group input[type="url"],
|
||||||
|
.field-group input[type="email"],
|
||||||
|
.field-group select,
|
||||||
|
.field-group textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.625rem 0.875rem;
|
||||||
|
border: 1px solid rgba(26, 34, 56, 0.2);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--nav-text);
|
||||||
|
background: var(--nav-surface);
|
||||||
|
font-family: inherit;
|
||||||
|
transition: border-color 0.15s, box-shadow 0.15s;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-group select {
|
||||||
|
cursor: pointer;
|
||||||
|
appearance: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-group input:focus,
|
||||||
|
.field-group select:focus,
|
||||||
|
.field-group textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--nav-primary-solid);
|
||||||
|
box-shadow: 0 0 0 2px rgba(245, 179, 66, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-group textarea {
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Erreur champ */
|
||||||
|
|
||||||
|
.field-error input,
|
||||||
|
.field-error select,
|
||||||
|
.field-error textarea {
|
||||||
|
border-color: #c0392b !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-msg {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #c0392b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-meta {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.char-count {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--nav-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.char-warn {
|
||||||
|
color: #e67e22;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Radio (Type entité) ─────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.radio-group {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radio-label {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
padding: 0.375rem 0.75rem;
|
||||||
|
border: 1px solid rgba(26, 34, 56, 0.2);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--nav-text);
|
||||||
|
background: var(--nav-surface);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radio-label input[type="radio"] {
|
||||||
|
position: absolute;
|
||||||
|
opacity: 0;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radio-label:hover {
|
||||||
|
border-color: var(--nav-primary-solid);
|
||||||
|
background: var(--nav-bg-alt);
|
||||||
|
}
|
||||||
|
|
||||||
|
.radio-label.active {
|
||||||
|
background: var(--nav-primary);
|
||||||
|
border-color: transparent;
|
||||||
|
color: var(--nav-text-on-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Checkboxes (Critères) ───────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.checkbox-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 400px) {
|
||||||
|
.checkbox-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-label {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
padding: 0.375rem 0.75rem;
|
||||||
|
border: 1px solid rgba(26, 34, 56, 0.2);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--nav-text);
|
||||||
|
background: var(--nav-surface);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-label input[type="checkbox"] {
|
||||||
|
position: absolute;
|
||||||
|
opacity: 0;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-label:hover:not(.disabled) {
|
||||||
|
border-color: var(--nav-primary-solid);
|
||||||
|
background: var(--nav-bg-alt);
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-label.active {
|
||||||
|
background: var(--nav-primary);
|
||||||
|
border-color: transparent;
|
||||||
|
color: var(--nav-text-on-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-label.disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Tags preview ────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.tags-preview {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.375rem;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.2rem 0.6rem;
|
||||||
|
background: var(--nav-bg-alt);
|
||||||
|
border: 1px solid rgba(26, 34, 56, 0.15);
|
||||||
|
border-radius: 100px;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--nav-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Erreur serveur ──────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.server-error {
|
||||||
|
padding: 0.875rem 1rem;
|
||||||
|
background: #fdf0ee;
|
||||||
|
border: 1px solid #e74c3c;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #c0392b;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Actions ──────────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
background: var(--nav-primary);
|
||||||
|
color: var(--nav-text-on-primary);
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
transition: background 0.15s, opacity 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover:not(:disabled) {
|
||||||
|
background: rgba(26, 34, 56, 0.75);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
padding: 0.75rem 1.25rem;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--nav-text-muted);
|
||||||
|
border: 1px solid rgba(26, 34, 56, 0.2);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
transition: border-color 0.15s, color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
border-color: var(--nav-primary-solid);
|
||||||
|
color: var(--nav-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-note {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--nav-text-muted);
|
||||||
|
text-align: center;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Responsive ──────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.contribuer-page {
|
||||||
|
padding: 1rem 0.75rem 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
flex-direction: column-reverse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary,
|
||||||
|
.btn-secondary {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,248 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="proposer-page">
|
|
||||||
<div class="proposer-inner">
|
|
||||||
<NuxtLink to="/" class="back-link">← Retour à la carte</NuxtLink>
|
|
||||||
|
|
||||||
<div class="proposer-header">
|
|
||||||
<h1>Proposer une ressource</h1>
|
|
||||||
<p class="proposer-subtitle">
|
|
||||||
Colle un lien (ou plusieurs), dis en quelques mots pourquoi c'est utile — on s'occupe du reste.
|
|
||||||
On valide manuellement avant publication.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="success" class="success-block" role="status" aria-live="polite">
|
|
||||||
<div class="success-icon">✓</div>
|
|
||||||
<h2>Merci !</h2>
|
|
||||||
<p>Ta proposition est en attente de modération.</p>
|
|
||||||
<p class="success-detail">Jules valide manuellement chaque entrée avant publication.</p>
|
|
||||||
<button type="button" class="btn-secondary" @click="reset">Proposer autre chose</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<template v-else>
|
|
||||||
<FormLibre :preset-type="presetType" @success="onSuccess" />
|
|
||||||
|
|
||||||
<div class="mode-detaille">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="mode-detaille-toggle"
|
|
||||||
:aria-expanded="detailleOuvert"
|
|
||||||
aria-controls="mode-detaille-panel"
|
|
||||||
@click="detailleOuvert = !detailleOuvert"
|
|
||||||
>
|
|
||||||
{{ detailleOuvert ? '− Fermer le mode détaillé' : '+ Mode détaillé (formulaires par catégorie)' }}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div v-if="detailleOuvert" id="mode-detaille-panel" class="mode-detaille-panel">
|
|
||||||
<p class="mode-detaille-hint">
|
|
||||||
Pour aller plus loin qu'un lien + quelques mots : échelle, fonctions, ville... un formulaire par catégorie.
|
|
||||||
</p>
|
|
||||||
<div class="tabs-bar">
|
|
||||||
<button
|
|
||||||
v-for="tab in TABS"
|
|
||||||
:key="tab.id"
|
|
||||||
type="button"
|
|
||||||
class="tab-btn"
|
|
||||||
:class="{ 'tab-btn--active': activeTab === tab.id }"
|
|
||||||
@click="activeTab = tab.id"
|
|
||||||
>{{ tab.label }}</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="tab-content">
|
|
||||||
<FormEcosysteme v-if="activeTab === 'ecosysteme'" @success="onSuccess" />
|
|
||||||
<FormReseau v-else-if="activeTab === 'reseau'" @success="onSuccess" />
|
|
||||||
<FormJobs v-else-if="activeTab === 'jobs'" @success="onSuccess" />
|
|
||||||
<FormOutils v-else-if="activeTab === 'outils'" @success="onSuccess" />
|
|
||||||
<FormReferences v-else-if="activeTab === 'references'" @success="onSuccess" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { TYPES_SUGGERES, type TypeSuggere } from '~/utils/submitLibre'
|
|
||||||
|
|
||||||
const TABS = [
|
|
||||||
{ id: 'ecosysteme', label: 'Écosystème / Entraide' },
|
|
||||||
{ id: 'reseau', label: 'Réseau AEP' },
|
|
||||||
{ id: 'jobs', label: 'Jobs' },
|
|
||||||
{ id: 'outils', label: 'Outils' },
|
|
||||||
{ id: 'references', label: 'Références (bibliothèque)' },
|
|
||||||
] as const
|
|
||||||
|
|
||||||
// ?categorie=outils (lien « Proposer une ressource » des têtes d'échelle) présélectionne
|
|
||||||
// le chip du formulaire assoupli ET l'onglet du mode détaillé si l'un ou l'autre est ouvert.
|
|
||||||
const route = useRoute()
|
|
||||||
const categorie = String(route.query.categorie ?? '')
|
|
||||||
const presetType = computed<TypeSuggere | undefined>(() =>
|
|
||||||
(TYPES_SUGGERES as readonly string[]).includes(categorie) ? (categorie as TypeSuggere) : undefined,
|
|
||||||
)
|
|
||||||
const activeTab = ref<string>(TABS.some(t => t.id === categorie) ? categorie : 'ecosysteme')
|
|
||||||
const detailleOuvert = ref(false)
|
|
||||||
const success = ref(false)
|
|
||||||
|
|
||||||
function onSuccess() {
|
|
||||||
success.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
function reset() {
|
|
||||||
success.value = false
|
|
||||||
activeTab.value = 'ecosysteme'
|
|
||||||
detailleOuvert.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
useHead({ title: 'Proposer une ressource — AEP' })
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.proposer-page {
|
|
||||||
min-height: 100vh;
|
|
||||||
background: var(--nav-bg);
|
|
||||||
padding: 1.5rem 1rem 4rem;
|
|
||||||
}
|
|
||||||
.proposer-inner {
|
|
||||||
max-width: 680px;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
.back-link {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.25rem;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
color: var(--nav-primary-solid);
|
|
||||||
opacity: 0.7;
|
|
||||||
text-decoration: none;
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
transition: opacity 0.15s;
|
|
||||||
}
|
|
||||||
.back-link:hover { opacity: 1; }
|
|
||||||
.proposer-header { margin-bottom: 1.5rem; }
|
|
||||||
.proposer-header h1 {
|
|
||||||
font-size: 1.5rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--nav-text);
|
|
||||||
margin: 0 0 0.5rem;
|
|
||||||
}
|
|
||||||
.proposer-subtitle {
|
|
||||||
font-size: 0.9rem;
|
|
||||||
color: var(--nav-text-muted);
|
|
||||||
line-height: 1.5;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mode-detaille {
|
|
||||||
margin-top: 2rem;
|
|
||||||
border-top: 1px solid var(--nav-bg-alt);
|
|
||||||
padding-top: 1.25rem;
|
|
||||||
}
|
|
||||||
.mode-detaille-toggle {
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
color: var(--nav-text-muted);
|
|
||||||
font-size: 0.85rem;
|
|
||||||
cursor: pointer;
|
|
||||||
font-family: inherit;
|
|
||||||
padding: 0.25rem 0;
|
|
||||||
text-decoration: underline;
|
|
||||||
text-underline-offset: 2px;
|
|
||||||
}
|
|
||||||
.mode-detaille-toggle:hover { color: var(--nav-text); }
|
|
||||||
.mode-detaille-panel { margin-top: 1rem; }
|
|
||||||
.mode-detaille-hint {
|
|
||||||
font-size: 0.8rem;
|
|
||||||
color: var(--nav-text-muted);
|
|
||||||
margin: 0 0 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tabs-bar {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.25rem;
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
border-bottom: 1px solid var(--nav-bg-alt);
|
|
||||||
padding-bottom: 0;
|
|
||||||
}
|
|
||||||
.tab-btn {
|
|
||||||
padding: 0.5rem 0.9rem;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--nav-text-muted);
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
border-bottom: 2px solid transparent;
|
|
||||||
cursor: pointer;
|
|
||||||
font-family: inherit;
|
|
||||||
transition: color 0.15s, border-color 0.15s;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.tab-btn:hover { color: var(--nav-text); }
|
|
||||||
.tab-btn--active {
|
|
||||||
color: var(--nav-text);
|
|
||||||
border-bottom-color: var(--nav-primary-solid);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
.tab-content {
|
|
||||||
min-height: 300px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.success-block {
|
|
||||||
background: var(--nav-surface);
|
|
||||||
border: 1px solid rgba(26, 34, 56, 0.15);
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 2rem 1.5rem;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
.success-icon {
|
|
||||||
width: 48px; height: 48px;
|
|
||||||
background: rgba(26, 34, 56, 0.1);
|
|
||||||
color: var(--nav-text);
|
|
||||||
border-radius: 50%;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-size: 1.25rem;
|
|
||||||
font-weight: 700;
|
|
||||||
margin: 0 auto 1rem;
|
|
||||||
}
|
|
||||||
.success-block h2 {
|
|
||||||
font-size: 1.25rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--nav-text);
|
|
||||||
margin: 0 0 0.5rem;
|
|
||||||
}
|
|
||||||
.success-block p {
|
|
||||||
font-size: 0.9rem;
|
|
||||||
color: var(--nav-text-muted);
|
|
||||||
margin: 0 0 0.5rem;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
.success-detail { font-size: 0.85rem !important; }
|
|
||||||
|
|
||||||
.btn-secondary {
|
|
||||||
padding: 0.75rem 1.25rem;
|
|
||||||
background: transparent;
|
|
||||||
color: var(--nav-text-muted);
|
|
||||||
border: 1px solid rgba(26, 34, 56, 0.2);
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
cursor: pointer;
|
|
||||||
font-family: inherit;
|
|
||||||
text-decoration: none;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
transition: border-color 0.15s, color 0.15s;
|
|
||||||
}
|
|
||||||
.btn-secondary:hover {
|
|
||||||
border-color: var(--nav-primary-solid);
|
|
||||||
color: var(--nav-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
|
||||||
.proposer-page { padding: 1rem 0.75rem 3rem; }
|
|
||||||
.tabs-bar { overflow-x: auto; flex-wrap: nowrap; }
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
+36
-1
@@ -1,3 +1,38 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex flex-col items-center justify-center h-full gap-6" style="background: var(--nav-bg);">
|
||||||
|
<div class="text-center max-w-md px-6">
|
||||||
|
<div
|
||||||
|
class="inline-flex items-center justify-center w-14 h-14 rounded-2xl mb-5"
|
||||||
|
style="background: var(--nav-bg-alt);"
|
||||||
|
>
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="color: var(--nav-text-muted);">
|
||||||
|
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/>
|
||||||
|
<polyline points="3.27 6.96 12 12.01 20.73 6.96"/>
|
||||||
|
<line x1="12" y1="22.08" x2="12" y2="12"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h1 class="text-2xl font-bold mb-3" style="color: var(--nav-text);">RAG — Retrieval Augmented Generation</h1>
|
||||||
|
<p class="text-sm leading-relaxed mb-6" style="color: var(--nav-text-muted);">
|
||||||
|
Une base de connaissances interrogeable par IA — textes, rapports, manifestes et ressources documentaires sur l'architecture d'écologie politique.
|
||||||
|
</p>
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-widest mb-6" style="color: var(--nav-text-muted); opacity: 0.6;">
|
||||||
|
Bientôt disponible
|
||||||
|
</p>
|
||||||
|
<NuxtLink
|
||||||
|
to="/"
|
||||||
|
class="inline-flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-semibold transition-all hover:opacity-80"
|
||||||
|
style="background: var(--nav-primary); color: var(--nav-text-on-primary);"
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" aria-hidden="true">
|
||||||
|
<line x1="19" y1="12" x2="5" y2="12"/>
|
||||||
|
<polyline points="12 19 5 12 12 5"/>
|
||||||
|
</svg>
|
||||||
|
Retour à l'écosystème
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
navigateTo('/media', { redirectCode: 301 })
|
useHead({ title: 'RAG — AEP (bientôt disponible)' })
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
Before Width: | Height: | Size: 6.1 KiB |
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
@@ -1,102 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "FMHY — Sélection Architecte",
|
|
||||||
"description": "~50 ressources curées depuis FMHY (Free Media Heck Yeah) — pertinentes pour un architecte, un praticien de la transition, un créateur solo.",
|
|
||||||
"children": [
|
|
||||||
{
|
|
||||||
"name": "IA & Outils cognitifs",
|
|
||||||
"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, 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." }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Lecture & Documentation",
|
|
||||||
"children": [
|
|
||||||
{ "name": "Anna's Archive", "url": "https://annas-archive.org/", "desc": "Bibliothèque shadow la plus complète du web. Livres, articles, thèses." },
|
|
||||||
{ "name": "Sci-Hub", "url": "https://sci-hub.se/", "desc": "Accès libre aux articles scientifiques payants." },
|
|
||||||
{ "name": "Library Genesis", "url": "https://libgen.is/", "desc": "Livres techniques et académiques en PDF." },
|
|
||||||
{ "name": "Z-Library", "url": "https://z-lib.id/", "desc": "Bibliothèque numérique massive, interface soignée." },
|
|
||||||
{ "name": "OpenLibrary (Internet Archive)", "url": "https://openlibrary.org/", "desc": "Prêt numérique gratuit, millions de livres." },
|
|
||||||
{ "name": "Calibre", "url": "https://calibre-ebook.com/", "desc": "Gestion de bibliothèque numérique, convertisseur de formats." }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Dessin & Modélisation",
|
|
||||||
"children": [
|
|
||||||
{ "name": "FreeCAD", "url": "https://www.freecad.org/", "desc": "Modélisation 3D open source, paramétrique. Alternative à Rhino pour usage simple." },
|
|
||||||
{ "name": "Blender", "url": "https://www.blender.org/", "desc": "3D, rendu, animation. La référence open source." },
|
|
||||||
{ "name": "Inkscape", "url": "https://inkscape.org/", "desc": "Dessin vectoriel. Alternative à Illustrator." },
|
|
||||||
{ "name": "GIMP", "url": "https://www.gimp.org/", "desc": "Retouche photo. Alternative à Photoshop." },
|
|
||||||
{ "name": "Krita", "url": "https://krita.org/", "desc": "Dessin digital et croquis. Excellent pour les concepts." },
|
|
||||||
{ "name": "LibreOffice Draw", "url": "https://www.libreoffice.org/", "desc": "Diagrammes, plans rapides, sans suite Adobe." }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Productivité & Texte",
|
|
||||||
"children": [
|
|
||||||
{ "name": "Obsidian", "url": "https://obsidian.md/", "desc": "PKM / prise de notes en Markdown. Gratuit pour usage personnel." },
|
|
||||||
{ "name": "Logseq", "url": "https://logseq.com/", "desc": "PKM open source, graphe de connaissances." },
|
|
||||||
{ "name": "Zotero", "url": "https://www.zotero.org/", "desc": "Gestionnaire de références bibliographiques. Indispensable pour la recherche." },
|
|
||||||
{ "name": "Marktext", "url": "https://github.com/marktext/marktext", "desc": "Éditeur Markdown WYSIWYG, open source." },
|
|
||||||
{ "name": "Typst", "url": "https://typst.app/", "desc": "Alternative moderne à LaTeX pour la mise en page de documents." },
|
|
||||||
{ "name": "Pandoc", "url": "https://pandoc.org/", "desc": "Conversion universelle entre formats de documents (MD, DOCX, PDF, HTML...)." }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Dev & Infrastructure",
|
|
||||||
"children": [
|
|
||||||
{ "name": "VS Code", "url": "https://code.visualstudio.com/", "desc": "Éditeur de code. La référence, gratuit." },
|
|
||||||
{ "name": "Coolify", "url": "https://coolify.io/", "desc": "Self-hosting simplifié. Alternative à Heroku/Vercel sur son propre VPS." },
|
|
||||||
{ "name": "Hetzner Cloud", "url": "https://www.hetzner.com/cloud/", "desc": "VPS européen, tarifs très bas, data centers Allemagne." },
|
|
||||||
{ "name": "Caddy", "url": "https://caddyserver.com/", "desc": "Serveur web avec HTTPS automatique. Plus simple que Nginx." },
|
|
||||||
{ "name": "n8n", "url": "https://n8n.io/", "desc": "Automatisation open source (comme Zapier mais self-hostable)." },
|
|
||||||
{ "name": "Gitea", "url": "https://gitea.io/", "desc": "Hébergement Git self-hosted. Alternative à GitHub." }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Vie privée & Sécurité",
|
|
||||||
"children": [
|
|
||||||
{ "name": "Bitwarden", "url": "https://bitwarden.com/", "desc": "Gestionnaire de mots de passe open source. Self-hostable." },
|
|
||||||
{ "name": "ProtonMail", "url": "https://proton.me/mail", "desc": "Email chiffré, hébergement Suisse." },
|
|
||||||
{ "name": "Signal", "url": "https://signal.org/", "desc": "Messagerie chiffrée E2E. La référence." },
|
|
||||||
{ "name": "uBlock Origin", "url": "https://ublockorigin.com/", "desc": "Bloqueur de publicités et trackers, le plus efficace." },
|
|
||||||
{ "name": "Mullvad VPN", "url": "https://mullvad.net/", "desc": "VPN respectueux de la vie privée, sans compte email requis." },
|
|
||||||
{ "name": "Privacy Guides", "url": "https://www.privacyguides.org/", "desc": "Recommandations d'outils respectueux de la vie privée, par thème." }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Formation & Apprentissage",
|
|
||||||
"children": [
|
|
||||||
{ "name": "MIT OpenCourseWare", "url": "https://ocw.mit.edu/", "desc": "Cours du MIT en libre accès, toutes disciplines." },
|
|
||||||
{ "name": "Khan Academy", "url": "https://www.khanacademy.org/", "desc": "Maths, sciences, programmation — gratuit, pédagogie excellente." },
|
|
||||||
{ "name": "YouTube (canaux techniques)", "url": "https://www.youtube.com/", "desc": "Channals : The Coding Train, Fireship, 3Blue1Brown, etc." },
|
|
||||||
{ "name": "freeCodeCamp", "url": "https://www.freecodecamp.org/", "desc": "Apprendre le développement web de zéro, gratuit et certifiant." },
|
|
||||||
{ "name": "Coursera (audit gratuit)", "url": "https://www.coursera.org/", "desc": "Cours universitaires, audit gratuit disponible sur la plupart." }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Médias & Audio",
|
|
||||||
"children": [
|
|
||||||
{ "name": "Audacity", "url": "https://www.audacityteam.org/", "desc": "Enregistrement et édition audio. Référence open source." },
|
|
||||||
{ "name": "yt-dlp", "url": "https://github.com/yt-dlp/yt-dlp", "desc": "Télécharger des vidéos/audio depuis YouTube et 1000+ sites." },
|
|
||||||
{ "name": "VLC", "url": "https://www.videolan.org/vlc/", "desc": "Lecteur multimédia universel." },
|
|
||||||
{ "name": "Kdenlive", "url": "https://kdenlive.org/", "desc": "Montage vidéo open source, non linéaire." },
|
|
||||||
{ "name": "OBS Studio", "url": "https://obsproject.com/", "desc": "Enregistrement et streaming vidéo. La référence gratuite." }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Divers Utiles",
|
|
||||||
"children": [
|
|
||||||
{ "name": "Nextcloud", "url": "https://nextcloud.com/", "desc": "Cloud personnel self-hosted. Alternative à Google Drive/Dropbox." },
|
|
||||||
{ "name": "Joplin", "url": "https://joplinapp.org/", "desc": "Notes chiffrées, sync Nextcloud, open source." },
|
|
||||||
{ "name": "draw.io / diagrams.net", "url": "https://app.diagrams.net/", "desc": "Diagrammes et schémas, gratuit, pas de compte requis." },
|
|
||||||
{ "name": "Excalidraw", "url": "https://excalidraw.com/", "desc": "Tableau blanc collaboratif, style hand-drawn, open source." },
|
|
||||||
{ "name": "Fmhy.net (complet)", "url": "https://fmhy.net/", "desc": "L'arbre complet. Des milliers de ressources organisées par thème." }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
{
|
|
||||||
"simulateurs": [
|
|
||||||
{
|
|
||||||
"id": "autonomie",
|
|
||||||
"icon": "🟢",
|
|
||||||
"titre": "Simulateur Autonomie",
|
|
||||||
"url": "https://calculs.trans-former.fr/autonomie/",
|
|
||||||
"description": "Évaluer le degré d'autonomie d'une famille à un site donné selon les ressources locales (eau, énergie, alimentation).",
|
|
||||||
"cta": "Lancer le simulateur →",
|
|
||||||
"tag": "outil-aep"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"simulateurs_inspirations": [
|
|
||||||
{
|
|
||||||
"id": "florquin-prix-m2",
|
|
||||||
"icon": "💡",
|
|
||||||
"titre": "Estimation prix au m² — Florquin Studio",
|
|
||||||
"url": "https://offre.florquinstudio.com/",
|
|
||||||
"description": "Une agence parisienne qui a construit un système d'estimation au m² assez fin. Pas dans nos outils, mais inspirant pour qui veut industrialiser son chiffrage.",
|
|
||||||
"tag": "inspiration-externe"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"opensource": [
|
|
||||||
{
|
|
||||||
"id": "dictee-universelle-groq",
|
|
||||||
"icon": "🎤",
|
|
||||||
"titre": "Dictée universelle Groq",
|
|
||||||
"url": "https://github.com/Jayjay-nene/dictee-universelle-groq",
|
|
||||||
"description": "Appuie sur une touche, parle, le texte apparaît au curseur avec ponctuation et majuscules auto. Transcription Whisper Groq < 1s. Marche dans toutes les applis Windows. Outil par Jayjay-nene.",
|
|
||||||
"tag": "recommande"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "atis-voice",
|
|
||||||
"icon": "🎙",
|
|
||||||
"titre": "Atis Voice (text-to-speech)",
|
|
||||||
"url": null,
|
|
||||||
"description": "Pipeline TTS pour transformer un texte en audio.",
|
|
||||||
"tag": "disponible"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "install-vps",
|
|
||||||
"icon": "🖥",
|
|
||||||
"titre": "Install VPS open source",
|
|
||||||
"url": null,
|
|
||||||
"description": "Setup pas-à-pas pour monter son propre VPS (Hetzner, Coolify, Caddy, Postgres…) en mode reproductible.",
|
|
||||||
"tag": "a-venir"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "skills-claude-code",
|
|
||||||
"icon": "⚙",
|
|
||||||
"titre": "Skills Claude Code",
|
|
||||||
"url": null,
|
|
||||||
"description": "Skills custom pour booster sa pratique avec un agent IA.",
|
|
||||||
"tag": "a-venir"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"bifurcation": {
|
|
||||||
"intro": "Beaucoup de jeunes diplômés en archi cherchent des chemins alternatifs. Cette section rassemble des témoignages, expériences, et ressources sur ce que c'est que de bifurquer.",
|
|
||||||
"videos_ofqa": [
|
|
||||||
{ "ep": "01", "titre": "Architectes indépendants", "personnes": "Jules Nény & Imane Fatmi", "url": "https://youtu.be/aMreB5KdNhY" },
|
|
||||||
{ "ep": "02", "titre": "Artiste & Maçon", "personnes": "Romane Dutour & Maël Canal", "url": "https://youtu.be/9gpjokx2ndI" },
|
|
||||||
{ "ep": "03", "titre": "Social & BTP", "personnes": "Célia Berdy & Esilda Perrot", "url": null, "note": "vidéo perdue, doc PDF seulement" },
|
|
||||||
{ "ep": "04", "titre": "Menuisier & Paysagiste", "personnes": "Adel Mohamedi & Julie Bowie", "url": "https://youtu.be/yKaRQhA3Z6g" },
|
|
||||||
{ "ep": "05", "titre": "Éco-construction", "personnes": "Edouard Vermès", "url": "https://youtu.be/97bDg1BjeuQ" },
|
|
||||||
{ "ep": "06", "titre": "Musicien & Urbaniste", "personnes": "Ruben Madar & Antoine Troccaz", "url": "https://drive.google.com/drive/folders/14g8YBn5bZAy8aIkHzQlOTrTtnWOaqRO3" },
|
|
||||||
{ "ep": "07", "titre": "AMO & Réemploi", "personnes": "Domitille Chaigne & Clémence Bondon", "url": "https://drive.google.com/file/d/1Q9Za81CElszmMn5n8dBsG0pJkiWmB32c/view" },
|
|
||||||
{ "ep": "08", "titre": "Gouvernance école", "personnes": "Solenn Guével", "url": "https://drive.google.com/drive/folders/1UaLsSyQcJydkXyV71klrY1tv9KgAh-mG" },
|
|
||||||
{ "ep": "bonus", "titre": "PFE — invitation à faire collectif", "personnes": "Jules Nény & Imane Fatmi", "url": "https://youtu.be/4qTEIC2Lmqw" }
|
|
||||||
],
|
|
||||||
"coalition_ensa_pb": {
|
|
||||||
"titre": "Coalition inter-asso ENSA-PB — victoire salle des enseignants",
|
|
||||||
"description": "Une coalition d'associations étudiantes a obtenu l'usage de la salle des enseignants pour des temps de travail collectif."
|
|
||||||
},
|
|
||||||
"ressources_externes": [
|
|
||||||
{
|
|
||||||
"id": "drop-the-kutch",
|
|
||||||
"icon": "🎧",
|
|
||||||
"titre": "Podcast Drop the Kutch — Sâm Afchar",
|
|
||||||
"url": "https://podcasts-francais.fr/podcast/drop-the-kutsch",
|
|
||||||
"description": "Témoignages de bifurcations post études d'archi. Super taf."
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"section_5_placeholder": {
|
|
||||||
"titre": "[V2 — Login] Logiciels pro",
|
|
||||||
"description": "Logiciels lourds (Adobe, AutoCAD…) — accès mutualisé. Disponible après création de compte AEP.",
|
|
||||||
"status": "bientot-login"
|
|
||||||
},
|
|
||||||
"footer_contribution": "Tu utilises un outil qui mérite d'être ici ? Écris-moi : contact@trans-former.fr"
|
|
||||||
}
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
{
|
|
||||||
"projets": [
|
|
||||||
{
|
|
||||||
"id": "quartier-2030",
|
|
||||||
"titre": "Votre quartier en 2030",
|
|
||||||
"auteurs": ["Inconnu"],
|
|
||||||
"annee": "2020",
|
|
||||||
"ecole": "Inconnu",
|
|
||||||
"url": "https://quartier-2030.firebaseapp.com/",
|
|
||||||
"description": "Exploration prospective confrontant smart city, no future, résilience et deep ecology à l'échelle du quartier. Le travail donne à voir plusieurs futurs urbains contrastés, de l'utopie technologique au retrait radical, en laissant le visiteur naviguer entre les scénarios. Un travail d'orfèvre pour sortir de la pensée linéaire sur la ville.",
|
|
||||||
"thumb": null,
|
|
||||||
"link_status": "ok"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "seine-nature",
|
|
||||||
"titre": "Seine — nature urbaine",
|
|
||||||
"auteurs": ["Inconnu"],
|
|
||||||
"annee": "2019",
|
|
||||||
"ecole": "Inconnu",
|
|
||||||
"url": "http://www.seine.natureurbaine.com/00_index/page_theme/theme.html",
|
|
||||||
"description": "Projet de transformation territoriale collective autour de la Seine, pensé comme une démarche systémique et pluridisciplinaire. L'intervention se concentre sur les marges périurbaines, traitées par une logique d'acupuncture : des micro-interventions précises pour enclencher des dynamiques plus larges. L'approche refuse le grand projet unique au profit d'un réseau de petites transformations.",
|
|
||||||
"thumb": null,
|
|
||||||
"link_status": "ok"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "tmip",
|
|
||||||
"titre": "TMIP — Transformation de la Maison Individuelle Périurbaine",
|
|
||||||
"auteurs": ["Jules Nény"],
|
|
||||||
"annee": "2019",
|
|
||||||
"ecole": "ENSA Paris-Belleville",
|
|
||||||
"url": "https://issuu.com/transformationresilientes/docs/tmip_archijeunes_cstb_",
|
|
||||||
"description": "Étude de la maison périurbaine sous l'angle des Gilets jaunes : comment ce lieu de vie concentre les tensions entre émancipation individuelle et dépendance structurelle (voiture, énergie, services). Le projet propose un réseau de micro-infrastructures partagées pour transformer ces maisons isolées en systèmes résilients interconnectés. Publié avec ARCHI'JEUNES et le CSTB.",
|
|
||||||
"thumb": null,
|
|
||||||
"link_status": "ok"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "filiere-bois",
|
|
||||||
"titre": "Enquête sur les paysages forestiers franciliens",
|
|
||||||
"auteurs": ["Quid Architecture"],
|
|
||||||
"annee": "2021",
|
|
||||||
"ecole": "Inconnu",
|
|
||||||
"url": "https://www.faireparis.com/fr/projets/faire-2021/enquete-sur-les-paysages-forestiers-franciliens-2159.html",
|
|
||||||
"description": "Projet lauréat FAIRE 2021. Enquête sur les dysfonctionnements de la filière bois en Île-de-France, aux interfaces entre sylviculteurs, scieries, artisans et maîtres d'ouvrage. Le travail cartographie les ruptures de filière et propose des interventions concrètes pour réparer les liens entre forêt et construction. Une démarche systémique rare dans les études architecturales.",
|
|
||||||
"thumb": null,
|
|
||||||
"link_status": "ok"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "jeu-champagne",
|
|
||||||
"titre": "Jeu de rôle Champagne PFE — Plateau",
|
|
||||||
"auteurs": ["Inconnu"],
|
|
||||||
"annee": "2020",
|
|
||||||
"ecole": "Inconnu",
|
|
||||||
"url": "https://campfe2020.wixsite.com/champagnepfe/plateau",
|
|
||||||
"description": "Dispositif ludique et coopératif développé comme outil de médiation entre acteurs d'un territoire. Le jeu de rôle permet de traverser des problèmes complexes en engageant simultanément des parties prenantes aux intérêts divergents. Une exploration de l'architecture comme processus collectif plutôt que comme objet produit.",
|
|
||||||
"thumb": null,
|
|
||||||
"link_status": "ok"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "transition-agricole",
|
|
||||||
"titre": "Transition agricole — réinvestissement de fermes traditionnelles",
|
|
||||||
"auteurs": ["Inconnu"],
|
|
||||||
"annee": "2020",
|
|
||||||
"ecole": "Inconnu",
|
|
||||||
"url": "https://www.calameo.com/books/007306483e0b23edb1db7",
|
|
||||||
"description": "Projet sur la transformation de fermes traditionnelles dans une logique agricole moderne et diversifiée. L'étude explore comment l'architecture peut accompagner les transitions d'usage des bâtiments ruraux, en articulant patrimonial et fonctionnel. Voir aussi le projet complémentaire sur la Seine aval : https://www.calameo.com/books/007063623f4d4b800b01d",
|
|
||||||
"thumb": null,
|
|
||||||
"link_status": "ok"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,809 +0,0 @@
|
|||||||
{
|
|
||||||
"meta": {
|
|
||||||
"version": "2026-05-06-T2",
|
|
||||||
"date_generation": "2026-05-06T18:30:00Z",
|
|
||||||
"total": 24,
|
|
||||||
"repartition": {
|
|
||||||
"recommande": 7,
|
|
||||||
"sous_reserve": 14,
|
|
||||||
"a_eviter": 3
|
|
||||||
},
|
|
||||||
"repartition_type": {
|
|
||||||
"b2c": 16,
|
|
||||||
"appel_offre_public": 8
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"plateformes": [
|
|
||||||
{
|
|
||||||
"id": "hemea",
|
|
||||||
"nom": "hemea",
|
|
||||||
"url": "https://www.hemea.com",
|
|
||||||
"type": "b2c-mise-en-relation",
|
|
||||||
"description": "## Présentation\nhemea (anciennement Travauxlib, rebaptisé en 2018) est une plateforme B-Corp certifiée depuis 2020, spécialisée rénovation et architecture. Elle gère 5 000+ projets depuis 2015 et dispose de 100+ experts dédiés. Modèle tiers de confiance avec séquestre des paiements.\n\n## Modèle économique\nCommission côté client de 5-10% (mission partielle) à 10-15% (mission complète) du montant HT. Les architectes intègrent le réseau comme prestataires coordonnés par hemea. La commission prélevée côté professionnel n\u0027est pas documentée — CGV introuvables sur le site (404).\n\n## Pour qui\nArchitectes et artisans cherchant un volume régulier de chantiers de rénovation, acceptant de travailler en sous-traitance coordonnée plutôt qu\u0027en relation directe client. Moins adapté aux indépendants souhaitant garder la main sur leur relation commerciale.\n\n## Points forts\nCertification B-Corp et positionnement RSE documenté. Séquestre des paiements protecteur pour les deux parties. Notoriété forte dans l\u0027écosystème rénovation français. Volume de projets significatif (5 000+ depuis 2015).\n\n## Points de vigilance\nLe modèle place l\u0027architecte en sous-traitant, pas en maître d\u0027œuvre autonome. Verbatims Trustpilot signalent une surfacturation liée à la double marge (hemea + artisan). En cas de litige, hemea se repositionne comme \u0027courtier\u0027, pas maître d\u0027œuvre — responsabilité diluée.",
|
|
||||||
"description_courte": "Courtier BTP B-Corp spécialisé rénovation, les architectes intègrent le réseau comme sous-traitants coordonnés. En cas de litige, hemea se repositionne comme simple courtier — responsabilité diluée.",
|
|
||||||
"scoring": {
|
|
||||||
"remuneration": "⚠️",
|
|
||||||
"transparence": "⚠️",
|
|
||||||
"pratiques": "⚠️",
|
|
||||||
"ecologie": "✅",
|
|
||||||
"matching": "⚠️",
|
|
||||||
"tag_global": "sous-reserve",
|
|
||||||
"justification_tag": "Modèle tiers de confiance avec commission 5-15% côté client et CGV introuvables — l\u0027architecte est sous-traitant coordonné, pas en relation directe. Opacité sur la rémunération côté professionnel et ambiguïté sur la responsabilité en cas de litige (Trustpilot 4.6/5, 976 avis, verbatims négatifs convergents sur ce point)."
|
|
||||||
},
|
|
||||||
"secteurs_servis": [
|
|
||||||
"renovation",
|
|
||||||
"construction-neuve",
|
|
||||||
"architecture-interieure",
|
|
||||||
"mar-conseil"
|
|
||||||
],
|
|
||||||
"zone_geo": "france-entiere",
|
|
||||||
"cout_entree": "commission",
|
|
||||||
"date_creation_fiche": "2026-05-06",
|
|
||||||
"date_derniere_maj": "2026-05-06",
|
|
||||||
"source_donnees": [
|
|
||||||
"https://r.jina.ai/https://www.hemea.com",
|
|
||||||
"https://fr.trustpilot.com/review/hemea.com"
|
|
||||||
],
|
|
||||||
"flag_validation_jules": false,
|
|
||||||
"commentaires": [
|
|
||||||
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "travaux-com",
|
|
||||||
"nom": "Travaux.com",
|
|
||||||
"url": "https://www.travaux.com",
|
|
||||||
"type": "b2c-mise-en-relation",
|
|
||||||
"description": "## Présentation\nPlateforme généraliste de mise en relation artisans-particuliers, parmi les plus anciennes en France. 63 207 artisans qualifiés référencés et 206 000+ avis clients. Dispose d\u0027une catégorie architecte (/architecte) mais reste dominée par les artisans BTP généralistes.\n\n## Modèle économique\nLead payant pour les professionnels : publication de projet gratuite pour le particulier, accès aux coordonnées payant pour le pro. Tarifs par lead non publiés, variables selon département et secteur. Upselling commercial vers des offres premium documenté.\n\n## Pour qui\nArtisans BTP généralistes en priorité. Pour les architectes, utilité limitée : visibilité réduite dans un catalogue majoritairement orienté artisanat. Potentiellement utile pour des missions rénovation simples en zone rurale avec peu d\u0027alternatives.\n\n## Points forts\nNotoriété grand public et volume de projets importants. Couverture nationale complète. Pas de commission prélevée sur les honoraires — tarification au lead uniquement. Marque reconnue depuis 20+ ans dans le secteur.\n\n## Points de vigilance\nDémarchage commercial agressif et upselling signalés par des professionnels inscrits. Qualité des leads variable. Note Trustpilot 3.9/5 sur 10 311 avis avec 16% de 1 étoile — révélateur de frustration chez les pros. Tarification opaque.",
|
|
||||||
"description_courte": "Plateforme généraliste leads BTP à notoriété grand public. Architectes noyés parmi les artisans, tarification opaque et démarchage commercial agressif signalé par des professionnels inscrits.",
|
|
||||||
"scoring": {
|
|
||||||
"remuneration": "⚠️",
|
|
||||||
"transparence": "⚠️",
|
|
||||||
"pratiques": "⚠️",
|
|
||||||
"ecologie": "❌",
|
|
||||||
"matching": "⚠️",
|
|
||||||
"tag_global": "sous-reserve",
|
|
||||||
"justification_tag": "Un ❌ sur l\u0027axe écologie (aucun positionnement ni pédagogie écologique sur une plateforme généraliste BTP) mais les 3 axes critiques restent en ⚠️. Upselling commercial agressif documenté côté pros (Trustpilot 3.9/5, 10 311 avis, 16% de 1 étoile)."
|
|
||||||
},
|
|
||||||
"secteurs_servis": [
|
|
||||||
"renovation",
|
|
||||||
"construction-neuve"
|
|
||||||
],
|
|
||||||
"zone_geo": "france-entiere",
|
|
||||||
"cout_entree": "lead-paye",
|
|
||||||
"date_creation_fiche": "2026-05-06",
|
|
||||||
"date_derniere_maj": "2026-05-06",
|
|
||||||
"source_donnees": [
|
|
||||||
"https://r.jina.ai/https://www.travaux.com/architecte",
|
|
||||||
"https://fr.trustpilot.com/review/travaux.com"
|
|
||||||
],
|
|
||||||
"flag_validation_jules": false,
|
|
||||||
"commentaires": [
|
|
||||||
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "habitatpresto",
|
|
||||||
"nom": "Habitatpresto",
|
|
||||||
"url": "https://www.habitatpresto.com",
|
|
||||||
"type": "b2c-mise-en-relation",
|
|
||||||
"description": "## Présentation\nPlateforme de mise en relation artisans-particuliers fondée en 2005 (21 ans d\u0027expertise). Interface de sélection par département et type de travaux. Équipe commerciale L-V 9h-18h. Couvre essentiellement l\u0027artisanat du bâtiment, avec une présence de profils architecture.\n\n## Modèle économique\nAbonnement mensuel fixe couvrant l\u0027accès illimité aux coordonnées clients. Tarif non publié — personnalisation téléphonique par région et catégorie. Frais de mise en service additionnels. La page /pro/tarifs est accessible mais n\u0027affiche aucun prix.\n\n## Pour qui\nArtisans du bâtiment cherchant des chantiers en volume. Pour les architectes indépendants, l\u0027utilité est très limitée : secteur principalement orienté artisanat, pas architecture de conception ou maîtrise d\u0027œuvre.\n\n## Points forts\nAbonnement à tarif fixe sans surprise par mission. Couverture nationale, présence dans tous les départements. Ancienneté du service (21 ans). Pas de commission par projet — modèle prévisible une fois le prix négocié.\n\n## Points de vigilance\nTarification entièrement opaque : aucun prix publié, devis uniquement par téléphone. Verbatim professionnel Trustpilot sévère : \u0027arnaque, 6 propositions reçues en 6 mois\u0027. Note 4.1/5 mais 22% de 1 étoile. Sortie d\u0027abonnement potentiellement difficile sans test de qualité préalable.",
|
|
||||||
"description_courte": "Abonnement artisans BTP à prix opaque (non publié). Verbatims pros très négatifs : \u0027arnaque, rien en retour en 6 mois.\u0027 Note 4.1/5 mais 22% de 1 étoile — inadapté aux architectes.",
|
|
||||||
"scoring": {
|
|
||||||
"remuneration": "⚠️",
|
|
||||||
"transparence": "❌",
|
|
||||||
"pratiques": "⚠️",
|
|
||||||
"ecologie": "❌",
|
|
||||||
"matching": "❌",
|
|
||||||
"tag_global": "a-eviter",
|
|
||||||
"justification_tag": "Transparence ❌ : aucun prix publié sur la page /pro/tarifs malgré une page dédiée — opacité tarifaire volontaire documentée (scraping T1 + page accessible sans prix). Matching ❌ : verbatim Trustpilot professionnel crédible (\u00276 propositions en 6 mois pour un abonnement coûteux\u0027, 1 732 avis, 22% de 1 étoile). Deux ❌ dont un critique suffisent à justifier \u0027à éviter\u0027."
|
|
||||||
},
|
|
||||||
"secteurs_servis": [
|
|
||||||
"renovation"
|
|
||||||
],
|
|
||||||
"zone_geo": "france-entiere",
|
|
||||||
"cout_entree": "abonnement",
|
|
||||||
"date_creation_fiche": "2026-05-06",
|
|
||||||
"date_derniere_maj": "2026-05-06",
|
|
||||||
"source_donnees": [
|
|
||||||
"https://r.jina.ai/https://www.habitatpresto.com/pro/tarifs",
|
|
||||||
"https://fr.trustpilot.com/review/habitatpresto.com"
|
|
||||||
],
|
|
||||||
"flag_validation_jules": true,
|
|
||||||
"commentaires": [
|
|
||||||
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "houzz-pro",
|
|
||||||
"nom": "Houzz Pro",
|
|
||||||
"url": "https://www.houzz.fr",
|
|
||||||
"type": "b2c-mise-en-relation",
|
|
||||||
"description": "## Présentation\nSolution SaaS tout-en-un pour professionnels de la rénovation et du design : gestion de projets, marketing, profil premium, publicité ciblée, plans 3D, devis, CRM, portail client. Plateforme américaine avec forte adoption internationale. 17 727 avis Trustpilot (4.1/5).\n\n## Modèle économique\nAbonnement mensuel couvrant l\u0027ensemble des fonctionnalités SaaS : portfolio, CRM, leads, publicité ciblée. Tarif estimé à ~250€/mois selon verbatim Trustpilot. Pas de commission sur les honoraires — l\u0027architecte conserve 100% de sa rémunération de mission.\n\n## Pour qui\nArchitectes d\u0027intérieur, architectes confirmés et studios cherchant un outil tout-en-un (portfolio + CRM + leads). Moins adapté aux architectes débutants ou aux profils cherchant uniquement des leads ponctuels sans investissement SaaS.\n\n## Points forts\nOutil SaaS complet (portfolio, CRM, devis, plan 3D intégrés). Forte notoriété internationale, large audience de particuliers. Pas de commission sur les missions — l\u0027architecte conserve 100% de ses honoraires.\n\n## Points de vigilance\nContrat difficile à résilier selon des verbatims Trustpilot : menace de poursuites judiciaires en cas de résiliation signalée. Coût mensuel significatif (~250€/mois estimé). Outil SaaS de gestion avant tout — la génération de leads est un effet secondaire, pas la valeur principale.",
|
|
||||||
"description_courte": "SaaS tout-en-un portfolio+CRM+leads pour architectes confirmés, sans commission sur honoraires. Contrat difficile à résilier — verbatim Trustpilot signale des menaces judiciaires en cas de résiliation anticipée.",
|
|
||||||
"scoring": {
|
|
||||||
"remuneration": "✅",
|
|
||||||
"transparence": "⚠️",
|
|
||||||
"pratiques": "⚠️",
|
|
||||||
"ecologie": "⚠️",
|
|
||||||
"matching": "⚠️",
|
|
||||||
"tag_global": "sous-reserve",
|
|
||||||
"justification_tag": "Rémunération ✅ (abonnement fixe, 0% commission sur les honoraires) mais pratiques de résiliation contractuelle problématiques signalées dans les verbatims Trustpilot (17 727 avis, 4.1/5). Trois axes critiques en ⚠️ — outil solide pour le portfolio mais engagement financier et contractuel à évaluer avec soin."
|
|
||||||
},
|
|
||||||
"secteurs_servis": [
|
|
||||||
"renovation",
|
|
||||||
"construction-neuve",
|
|
||||||
"architecture-interieure"
|
|
||||||
],
|
|
||||||
"zone_geo": "france-entiere",
|
|
||||||
"cout_entree": "abonnement",
|
|
||||||
"date_creation_fiche": "2026-05-06",
|
|
||||||
"date_derniere_maj": "2026-05-06",
|
|
||||||
"source_donnees": [
|
|
||||||
"https://r.jina.ai/https://www.houzz.fr/pro",
|
|
||||||
"https://fr.trustpilot.com/review/www.houzz.com"
|
|
||||||
],
|
|
||||||
"flag_validation_jules": false,
|
|
||||||
"commentaires": [
|
|
||||||
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "hello-archi",
|
|
||||||
"nom": "Hello Archi",
|
|
||||||
"url": "https://hello-archi.com",
|
|
||||||
"type": "b2c-mise-en-relation",
|
|
||||||
"description": "## Présentation\nPlateforme de mise en relation complète particuliers-architectes qualifiés, basée à Périgueux. Gère l\u0027intégralité du projet : sélection architecte, échanges, signature de contrat et paiement. Jusqu\u0027à 3 architectes mis en contact par projet. Couvre projets résidentiels et commerciaux, permis de construire inclus.\n\n## Modèle économique\nCommission sur le montant de la mission (taux non public). CGV documentées et accessibles sur /cgu. Pénalité de 100% des honoraires si l\u0027architecte signe un contrat hors plateforme sans le déclarer sous 10 jours. Frais administratifs de 350€ si le client est silencieux 15+ jours.\n\n## Pour qui\nArchitectes souhaitant une mise en relation structurée avec suivi de projet intégré (contrat, paiement). Adapté aux profils acceptant les contraintes contractuelles. Moins adapté aux freelances souhaitant une relation commerciale totalement libre.\n\n## Points forts\nCGV accessibles et détaillées — rare parmi les plateformes B2C du panel. Accompagnement expert tout au long du projet. Couverture nationale complète. Commission uniquement à la contractualisation, pas de frais d\u0027inscription documentés.\n\n## Points de vigilance\nPénalité de 100% des honoraires si contrat hors plateforme non déclaré sous 10 jours — clause très contraignante. Mécanisme de médiation en litige \u0027promis mais pas encore opérationnel\u0027 selon les CGV. Commission exacte non publiée.",
|
|
||||||
"description_courte": "Mise en relation avec CGV claires mais clause contraignante : pénalité 100% des honoraires si contrat signé hors plateforme sans déclaration sous 10 jours. Commission non publiée.",
|
|
||||||
"scoring": {
|
|
||||||
"remuneration": "⚠️",
|
|
||||||
"transparence": "⚠️",
|
|
||||||
"pratiques": "⚠️",
|
|
||||||
"ecologie": "⚠️",
|
|
||||||
"matching": "⚠️",
|
|
||||||
"tag_global": "sous-reserve",
|
|
||||||
"justification_tag": "Cinq axes en ⚠️ : commission non publiée, mécanisme de médiation non opérationnel selon les CGV elles-mêmes, et clause de pénalité de 100% des honoraires si contrat hors plateforme non déclaré sous 10 jours — contrainte contractuelle significative sans contrepartie documentée."
|
|
||||||
},
|
|
||||||
"secteurs_servis": [
|
|
||||||
"renovation",
|
|
||||||
"construction-neuve",
|
|
||||||
"architecture-interieure"
|
|
||||||
],
|
|
||||||
"zone_geo": "france-entiere",
|
|
||||||
"cout_entree": "commission",
|
|
||||||
"date_creation_fiche": "2026-05-06",
|
|
||||||
"date_derniere_maj": "2026-05-06",
|
|
||||||
"source_donnees": [
|
|
||||||
"https://r.jina.ai/https://hello-archi.com",
|
|
||||||
"https://hello-archi.com/cgu"
|
|
||||||
],
|
|
||||||
"flag_validation_jules": false,
|
|
||||||
"commentaires": [
|
|
||||||
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "archidvisor",
|
|
||||||
"nom": "Archidvisor",
|
|
||||||
"url": "https://www.archidvisor.com",
|
|
||||||
"type": "b2c-mise-en-relation",
|
|
||||||
"description": "## Présentation\nMarketplace archi et design fondée en novembre 2016 à Bordeaux par un architecte de formation. Connecte particuliers et entreprises avec architectes, architectes d\u0027intérieur, décorateurs, paysagistes et maîtres d\u0027œuvre. 2 200+ agences inscrites. Protection juridique MMA incluse. Lauréate des Rencontres des Entrepreneurs.\n\n## Modèle économique\nFreemium : inscription et référencement entièrement gratuits. Commission de 7-9% du montant total uniquement à la contractualisation (no cure no pay). Option abonnement Premium 1 499€/an ou 299€/mois pour visibilité accrue. Aucun frais si aucun projet conclu.\n\n## Pour qui\nArchitectes, architectes d\u0027intérieur, paysagistes et maîtres d\u0027œuvre cherchant une mise en relation sans investissement initial. Particulièrement adapté aux débutants (entrée sans frais) ou aux agences confirmées investissant dans le Premium pour la visibilité.\n\n## Points forts\nModèle no cure no pay — commission uniquement si projet signé. Entrée gratuite sans risque. Fondée par un architecte — compréhension du métier. Protection juridique MMA incluse. Couvre plusieurs profils (archi, déco, paysage).\n\n## Points de vigilance\nArchidvisor obtient une licence illimitée dans le temps pour utiliser photos et plans publiés à des fins marketing. Interdiction de contacter directement les clients hors plateforme. Verbatims Trustpilot signalent des profils avec projets IA et budgets irréalistes (3.9/5, 198 avis).",
|
|
||||||
"description_courte": "Plateforme fondée par un architecte, modèle no cure no pay (commission 7-9% à la signature seulement). Attention : licence illimitée sur vos photos et plans pour usage marketing Archidvisor.",
|
|
||||||
"scoring": {
|
|
||||||
"remuneration": "✅",
|
|
||||||
"transparence": "⚠️",
|
|
||||||
"pratiques": "⚠️",
|
|
||||||
"ecologie": "⚠️",
|
|
||||||
"matching": "⚠️",
|
|
||||||
"tag_global": "sous-reserve",
|
|
||||||
"justification_tag": "Rémunération ✅ : commission 7-9% no cure no pay — modèle favorable avec marge \u003e91% conservée. Mais clause de licence illimitée sur photos/plans (CGV vérifiées sur /p/cgu-cgv) et verbatims Trustpilot professionnels mitigés sur la qualité des leads (3.9/5, 198 avis). Quatre axes en ⚠️."
|
|
||||||
},
|
|
||||||
"secteurs_servis": [
|
|
||||||
"renovation",
|
|
||||||
"construction-neuve",
|
|
||||||
"architecture-interieure",
|
|
||||||
"paysage"
|
|
||||||
],
|
|
||||||
"zone_geo": "france-entiere",
|
|
||||||
"cout_entree": "freemium",
|
|
||||||
"date_creation_fiche": "2026-05-06",
|
|
||||||
"date_derniere_maj": "2026-05-06",
|
|
||||||
"source_donnees": [
|
|
||||||
"https://www.archidvisor.com/p/cgu-cgv",
|
|
||||||
"https://www.ooti.co/fr/blogs/networking-plateformes-architecture",
|
|
||||||
"https://fr.trustpilot.com/review/archidvisor.com"
|
|
||||||
],
|
|
||||||
"flag_validation_jules": false,
|
|
||||||
"commentaires": [
|
|
||||||
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "bam-archi",
|
|
||||||
"nom": "BAM Archi",
|
|
||||||
"url": "https://www.bam.archi",
|
|
||||||
"type": "b2c-mise-en-relation",
|
|
||||||
"description": "## Présentation\nService d\u0027accompagnement rénovation et construction à destination des particuliers. Sélection d\u0027architectes et artisans pour les clients via matching personnalisé. ~3 000 projets/an gérés, 6 000 agences référencées. Présence Paris, Marseille, Bordeaux, Lyon. Applications complémentaires : Aglo et Aglo Carbone (RE2020).\n\n## Modèle économique\nModèle économique non documenté publiquement. La grille tarifaire pour les professionnels n\u0027est pas accessible sur le site. Marketplace avec accompagnement client — commission ou frais d\u0027inscription pour les architectes à confirmer directement auprès de BAM.\n\n## Pour qui\nArchitectes confirmés dans les grandes métropoles françaises (Paris, Marseille, Bordeaux, Lyon) cherchant un volume de projets rénovation-construction. Fort intérêt pour les profils maîtrisant les outils RE2020 et bilan carbone.\n\n## Points forts\nIntégration des outils Aglo Carbone (RE2020) — signal positif sur l\u0027engagement environnemental. Accompagnement structuré des projets. Présence métropolitaine dense. Volume de projets significatif (~3 000/an).\n\n## Points de vigilance\nModèle économique entièrement opaque : impossible d\u0027évaluer le coût réel pour l\u0027architecte sans contact direct. 6 000 agences référencées = forte concurrence interne pour chaque lead. Pas de feedback communauté disponible (Trustpilot absent).",
|
|
||||||
"description_courte": "Accompagnement rénovation avec outils RE2020 et Aglo Carbone intégrés. Modèle économique entièrement opaque — coût pour l\u0027architecte impossible à évaluer sans contact direct avec BAM.",
|
|
||||||
"scoring": {
|
|
||||||
"remuneration": "⚠️",
|
|
||||||
"transparence": "⚠️",
|
|
||||||
"pratiques": "⚠️",
|
|
||||||
"ecologie": "✅",
|
|
||||||
"matching": "⚠️",
|
|
||||||
"tag_global": "sous-reserve",
|
|
||||||
"justification_tag": "Écologie ✅ : intégration d\u0027Aglo Carbone (RE2020) — effort concret et documenté sur la transition écologique. Mais modèle économique entièrement opaque pour les professionnels (aucune grille tarifaire publique) et absence totale de feedback communauté."
|
|
||||||
},
|
|
||||||
"secteurs_servis": [
|
|
||||||
"renovation",
|
|
||||||
"construction-neuve",
|
|
||||||
"architecture-interieure"
|
|
||||||
],
|
|
||||||
"zone_geo": "france-entiere",
|
|
||||||
"cout_entree": "gratuit",
|
|
||||||
"date_creation_fiche": "2026-05-06",
|
|
||||||
"date_derniere_maj": "2026-05-06",
|
|
||||||
"source_donnees": [
|
|
||||||
"https://r.jina.ai/https://www.bam.archi",
|
|
||||||
"https://www.ooti.co/fr/blogs/networking-plateformes-architecture"
|
|
||||||
],
|
|
||||||
"flag_validation_jules": false,
|
|
||||||
"commentaires": [
|
|
||||||
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "archibien",
|
|
||||||
"nom": "Archibien",
|
|
||||||
"url": "https://archibien.com",
|
|
||||||
"type": "b2c-mise-en-relation",
|
|
||||||
"description": "## Présentation\nPlateforme de courtage mettant des porteurs de projets en relation avec 3 architectes locaux en concurrence simultanée. Fondée ~2016. Qualification préalable du projet (faisabilité, budget). Consultations initiales offertes aux clients. Présente dans les grandes métropoles françaises. Secteurs : neuf, extension, rénovation, commercial.\n\n## Modèle économique\nModèle broker : les clients paient pour les services (consultation, étude de faisabilité). Les architectes semblent payer à la contractualisation ou pour accéder aux projets — grille tarifaire non accessible, CGV introuvables (/cgv → 404). Opacité tarifaire totale côté professionnel.\n\n## Pour qui\nÀ évaluer avec prudence pour les architectes indépendants : le modèle de 3 architectes en concurrence implique un travail préparatoire sans garantie de mission. Adapté uniquement aux agences ayant des ressources commerciales suffisantes pour absorber les pertes sur concours non retenus.\n\n## Points forts\nQualification préalable du projet côté client (faisabilité et budget évalués en amont). Présence dans les grandes métropoles. Pas de feedback négatif public visible — profil Trustpilot non revendiqué (0 avis).\n\n## Points de vigilance\nCGV introuvables (404) : aucune condition contractuelle vérifiable. Modèle \u00273 archis en concurrence\u0027 avec consultations offertes au client : risque de travail de conception non rémunéré, contraire au Code de déontologie. Opacité tarifaire totale.",
|
|
||||||
"description_courte": "Modèle 3 architectes en concurrence avec consultations offertes. CGV introuvables (/cgv → 404) — conditions contractuelles invérifiables, risque de travail non rémunéré non documenté.",
|
|
||||||
"scoring": {
|
|
||||||
"remuneration": "⚠️",
|
|
||||||
"transparence": "❌",
|
|
||||||
"pratiques": "⚠️",
|
|
||||||
"ecologie": "⚠️",
|
|
||||||
"matching": "⚠️",
|
|
||||||
"tag_global": "a-eviter",
|
|
||||||
"justification_tag": "Transparence ❌ : CGV introuvables (/cgv → 404) — aucune condition contractuelle vérifiable publiquement, opacité totale sur les conditions d\u0027utilisation (documenté lors du scraping T1). Modèle de concurrence simultanée entre 3 architectes avec consultations offertes soulève des questions déontologiques. Un axe critique ❌ suffit pour le tag \u0027à éviter\u0027."
|
|
||||||
},
|
|
||||||
"secteurs_servis": [
|
|
||||||
"renovation",
|
|
||||||
"construction-neuve",
|
|
||||||
"architecture-interieure"
|
|
||||||
],
|
|
||||||
"zone_geo": "france-entiere",
|
|
||||||
"cout_entree": "commission",
|
|
||||||
"date_creation_fiche": "2026-05-06",
|
|
||||||
"date_derniere_maj": "2026-05-06",
|
|
||||||
"source_donnees": [
|
|
||||||
"https://r.jina.ai/https://archibien.com",
|
|
||||||
"https://www.ooti.co/fr/blogs/networking-plateformes-architecture"
|
|
||||||
],
|
|
||||||
"flag_validation_jules": true,
|
|
||||||
"commentaires": [
|
|
||||||
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "archionline",
|
|
||||||
"nom": "Archionline",
|
|
||||||
"url": "https://www.archionline.com",
|
|
||||||
"type": "b2c-mise-en-relation",
|
|
||||||
"description": "## Présentation\nPlateforme de mise en relation particuliers-architectes appartenant au groupe Batiweb. Siège à Paris (19 rue d\u0027Hauteville, 75010). Déploiement d\u0027architectes sur site sous 1 semaine. Garantie décennale et protection juridique AXA incluses. 600+ plans de maison disponibles. Active depuis au moins 2017.\n\n## Modèle économique\nCommission de 5-15% sur le montant total des travaux selon la nature de la mission (conception, permis de construire, analyse entreprises). Étude initiale gratuite pour le client. Taux documenté via source tierce (blog Hello Archi, janvier 2025).\n\n## Pour qui\nEn théorie, architectes cherchant des projets résidentiels clés en main. En pratique, les retours Trustpilot très négatifs et les pratiques de démarchage abusif signalées exposent les architectes affiliés à des risques réputationnels significatifs.\n\n## Points forts\nCommission documentée dans une fourchette acceptable (5-15%). Garantie décennale et protection AXA incluses. Couverture nationale. Intégration Groupe Batiweb — synergies avec des médias pro du bâtiment.\n\n## Points de vigilance\nNote Trustpilot de 2.4/5 sur 207 avis — parmi les plus basses du panel. Démarchage abusif signalé : harcèlement téléphonique après dépôt de coordonnées client. Permis de construire non conformes aux PLU documentés. Ces pratiques exposent les architectes affiliés.",
|
|
||||||
"description_courte": "Commission 5-15% documentée mais note Trustpilot 2.4/5 sur 207 avis. Démarchage abusif et permis non conformes aux PLU signalés — risque réputationnel sérieux pour les architectes affiliés.",
|
|
||||||
"scoring": {
|
|
||||||
"remuneration": "✅",
|
|
||||||
"transparence": "⚠️",
|
|
||||||
"pratiques": "❌",
|
|
||||||
"ecologie": "⚠️",
|
|
||||||
"matching": "❌",
|
|
||||||
"tag_global": "a-eviter",
|
|
||||||
"justification_tag": "Pratiques ❌ : démarchage abusif (harcèlement téléphonique) et permis de construire non conformes aux PLU documentés dans les verbatims Trustpilot (2.4/5, 207 avis, sources publiques vérifiables). Matching ❌ convergent avec la note Trustpilot parmi les plus basses du panel. Un axe critique ❌ (Pratiques) suffit pour le tag \u0027à éviter\u0027."
|
|
||||||
},
|
|
||||||
"secteurs_servis": [
|
|
||||||
"renovation",
|
|
||||||
"construction-neuve",
|
|
||||||
"mar-conseil"
|
|
||||||
],
|
|
||||||
"zone_geo": "france-entiere",
|
|
||||||
"cout_entree": "commission",
|
|
||||||
"date_creation_fiche": "2026-05-06",
|
|
||||||
"date_derniere_maj": "2026-05-06",
|
|
||||||
"source_donnees": [
|
|
||||||
"https://r.jina.ai/https://www.archionline.com",
|
|
||||||
"https://blog.hello-archi.com/top-3-plateformes-pour-engager-un-architecte-a-moindre-cout/",
|
|
||||||
"https://fr.trustpilot.com/review/archionline.com"
|
|
||||||
],
|
|
||||||
"flag_validation_jules": true,
|
|
||||||
"commentaires": [
|
|
||||||
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "trouver-mon-architecte",
|
|
||||||
"nom": "Trouver-Mon-Architecte",
|
|
||||||
"url": "https://www.trouver-mon-architecte.fr",
|
|
||||||
"type": "b2c-mise-en-relation",
|
|
||||||
"description": "## Présentation\nAnnuaire et plateforme de mise en relation, se présentant comme \u0027#1 annuaire d\u0027architectes qualifiés en France\u0027. Service gratuit pour les particuliers. Couverture nationale complète (95 départements). Les particuliers reçoivent 2-3 architectes adaptés sous 24-48h. 1 204 abonnés LinkedIn.\n\n## Modèle économique\nFreemium : gratuit sans engagement pour les particuliers. Abonnement payant pour les architectes inscrits (tarif non publié), incluant des formations professionnelles continues. Pas de commission prélevée sur les honoraires — l\u0027architecte conserve 100% de sa rémunération de mission.\n\n## Pour qui\nArchitectes indépendants souhaitant développer leur activité sans commission par mission. Particulièrement adapté aux profils intéressés par la formation continue intégrée. Couverture nationale utile pour les architectes hors grandes métropoles.\n\n## Points forts\nPas de commission sur les honoraires — abonnement fixe. Formation continue incluse dans l\u0027abonnement (valeur ajoutée différenciante). Note 4.5/5 sur 361 avis Trustpilot. Verbatim archi positif documenté : ~40 demandes, 4 contrats signés.\n\n## Points de vigilance\nTarifs abonnement non publiés — à vérifier avant tout engagement. Taux de conversion estimé à 10% (4 contrats sur 40 demandes selon verbatim) — en dessous du seuil optimal. Veille marchés publics annoncée mais détails peu documentés.",
|
|
||||||
"description_courte": "Abonnement archi incluant formations professionnelles, sans commission sur honoraires. Taux de conversion ~10% selon verbatim (4/40 demandes). Tarifs non publiés à vérifier avant engagement.",
|
|
||||||
"scoring": {
|
|
||||||
"remuneration": "✅",
|
|
||||||
"transparence": "⚠️",
|
|
||||||
"pratiques": "✅",
|
|
||||||
"ecologie": "⚠️",
|
|
||||||
"matching": "⚠️",
|
|
||||||
"tag_global": "sous-reserve",
|
|
||||||
"justification_tag": "Rémunération ✅ (0% commission, abonnement fixe) et Pratiques ✅ (formations incluses, pas de mise en concurrence, structure respectueuse du métier). Mais tarifs abonnement non publiés et taux de conversion ~10% (en dessous du seuil ✅ de \u003e20%). Trois axes en ⚠️ — tag sous-réserve."
|
|
||||||
},
|
|
||||||
"secteurs_servis": [
|
|
||||||
"renovation",
|
|
||||||
"construction-neuve",
|
|
||||||
"urbanisme",
|
|
||||||
"mar-conseil"
|
|
||||||
],
|
|
||||||
"zone_geo": "france-entiere",
|
|
||||||
"cout_entree": "freemium",
|
|
||||||
"date_creation_fiche": "2026-05-06",
|
|
||||||
"date_derniere_maj": "2026-05-06",
|
|
||||||
"source_donnees": [
|
|
||||||
"https://r.jina.ai/https://www.trouver-mon-architecte.fr",
|
|
||||||
"https://fr.trustpilot.com/review/trouver-mon-architecte.fr"
|
|
||||||
],
|
|
||||||
"flag_validation_jules": false,
|
|
||||||
"commentaires": [
|
|
||||||
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "archiliste",
|
|
||||||
"nom": "Archiliste",
|
|
||||||
"url": "https://www.archiliste.fr",
|
|
||||||
"type": "b2c-mise-en-relation",
|
|
||||||
"description": "## Présentation\nAnnuaire et plateforme de présentation des architectes de France. 26 660 agences enregistrées. Publication de projets gratuite pour tous les professionnels. Héberge également actualités, ressources formations et événements networking. Secteurs couverts : résidentiel, commercial, intérieur, rénovation, équipements publics.\n\n## Modèle économique\nFreemium : inscription et publication de projets entièrement gratuits. Fonctionnalités avancées (marketing, publicité, portail pro) payantes — tarifs non précisés. Revenu basé sur la visibilité premium et les contacts marketing.\n\n## Pour qui\nArchitectes souhaitant une vitrine portfolio gratuite à l\u0027échelle nationale. Utile pour la présence en ligne sans engagement financier. Moins utile pour la génération directe de leads : plateforme passive sans matching actif.\n\n## Points forts\nInscription et présence de base entièrement gratuites. Large base d\u0027agences (26 660) = crédibilité annuaire. Présence dans les secteurs public et privé. Ressources et événements networking en complément.\n\n## Points de vigilance\nPlateforme annuaire passive : aucun mécanisme de mise en relation active ou de génération de leads qualifiés. 26 660 agences enregistrées signifient une visibilité très diluée sans abonnement premium. Pas de feedback communauté disponible.",
|
|
||||||
"description_courte": "Annuaire national avec présence de base gratuite pour 26 660 agences. Vitrine portfolio sans génération de leads active — utile en complément, pas comme canal de prospection principal.",
|
|
||||||
"scoring": {
|
|
||||||
"remuneration": "✅",
|
|
||||||
"transparence": "⚠️",
|
|
||||||
"pratiques": "✅",
|
|
||||||
"ecologie": "⚠️",
|
|
||||||
"matching": "⚠️",
|
|
||||||
"tag_global": "sous-reserve",
|
|
||||||
"justification_tag": "Rémunération ✅ (inscription gratuite, 0% commission) et Pratiques ✅ (annuaire passif respectueux, pas de mise en concurrence). Trois axes en ⚠️ dont Matching — la plateforme est passive et ne génère pas de leads qualifiés. Utile comme présence complémentaire."
|
|
||||||
},
|
|
||||||
"secteurs_servis": [
|
|
||||||
"renovation",
|
|
||||||
"construction-neuve",
|
|
||||||
"architecture-interieure",
|
|
||||||
"transversal"
|
|
||||||
],
|
|
||||||
"zone_geo": "france-entiere",
|
|
||||||
"cout_entree": "freemium",
|
|
||||||
"date_creation_fiche": "2026-05-06",
|
|
||||||
"date_derniere_maj": "2026-05-06",
|
|
||||||
"source_donnees": [
|
|
||||||
"https://r.jina.ai/https://www.archiliste.fr/annuaire"
|
|
||||||
],
|
|
||||||
"flag_validation_jules": false,
|
|
||||||
"commentaires": [
|
|
||||||
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "architectes-pour-tous",
|
|
||||||
"nom": "Architectes pour tous (CNOA)",
|
|
||||||
"url": "https://www.architectes-pour-tous.fr",
|
|
||||||
"type": "b2c-mise-en-relation",
|
|
||||||
"description": "## Présentation\nService officiel du Conseil National de l\u0027Ordre des Architectes (CNOA). Référence l\u0027ensemble des architectes exerçant légalement en France. Recherche par profil (particulier, pro, collectivité), type de projet, proximité géographique. Carte interactive. Intégré aux dispositifs France Rénov\u0027 et MaPrimeRénov\u0027.\n\n## Modèle économique\nTotalement gratuit pour les particuliers et pour les architectes. L\u0027inscription à l\u0027Ordre est une obligation légale — la plateforme n\u0027implique aucun coût supplémentaire. Aucune commission, aucun abonnement, aucun frais caché. Financé par les cotisations ordinales.\n\n## Pour qui\nTous les architectes inscrits à l\u0027Ordre : la présence est automatique. Particulièrement pertinent pour les architectes spécialisés MAR ou rénovation énergétique, directement référencés dans les dispositifs publics d\u0027aide à la rénovation.\n\n## Points forts\nGratuit, institutionnel, référencement automatique pour tout architecte inscrit à l\u0027Ordre. Intégration aux dispositifs publics (France Rénov\u0027, MaPrimeRénov\u0027). Crédibilité institutionnelle maximale. Couverture nationale totale.\n\n## Points de vigilance\nAnnuaire passif institutionnel : ne génère pas de leads directs. Tous les architectes inscrits à l\u0027Ordre y figurent — différenciation nulle. Outil de présence publique minimum, pas un canal de prospection actif.",
|
|
||||||
"description_courte": "Annuaire officiel du Conseil de l\u0027Ordre, gratuit et intégré à France Rénov\u0027 et MaPrimeRénov\u0027. Présence automatique pour tout architecte inscrit — ne génère pas de leads directs.",
|
|
||||||
"scoring": {
|
|
||||||
"remuneration": "✅",
|
|
||||||
"transparence": "✅",
|
|
||||||
"pratiques": "✅",
|
|
||||||
"ecologie": "✅",
|
|
||||||
"matching": "⚠️",
|
|
||||||
"tag_global": "recommande",
|
|
||||||
"justification_tag": "Quatre axes en ✅ : gratuit, institutionnel, intégré aux dispositifs de rénovation énergétique (France Rénov\u0027, MaPrimeRénov\u0027), pratiques respectueuses de l\u0027Ordre. Seul le matching est en ⚠️ du fait de la nature passive de l\u0027annuaire — mais la présence y est obligatoire et sans coût."
|
|
||||||
},
|
|
||||||
"secteurs_servis": [
|
|
||||||
"renovation",
|
|
||||||
"construction-neuve",
|
|
||||||
"urbanisme",
|
|
||||||
"mar-conseil",
|
|
||||||
"transversal"
|
|
||||||
],
|
|
||||||
"zone_geo": "france-entiere",
|
|
||||||
"cout_entree": "gratuit",
|
|
||||||
"date_creation_fiche": "2026-05-06",
|
|
||||||
"date_derniere_maj": "2026-05-06",
|
|
||||||
"source_donnees": [
|
|
||||||
"https://r.jina.ai/https://www.architectes-pour-tous.fr"
|
|
||||||
],
|
|
||||||
"flag_validation_jules": false,
|
|
||||||
"commentaires": [
|
|
||||||
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "pipcke",
|
|
||||||
"nom": "Pipcke",
|
|
||||||
"url": "https://pipcke.fr",
|
|
||||||
"type": "b2c-mise-en-relation",
|
|
||||||
"description": "## Présentation\nPlateforme d\u0027architecture d\u0027intérieur et décoration en ligne. Met en relation clients avec architectes d\u0027intérieur et décorateurs via mood boards, shopping list et conception 3D photoréaliste. 1 000+ espaces transformés. 500+ marques partenaires avec remises négociées. Approche projet par pièce.\n\n## Modèle économique\nForfait par pièce : Essentiel 90€ (1 sem, mood boards + shopping list), Incontournable 155€ (1-2 sem, +3D + révisions), Fantastique 235€ (2-3 sem, 2 designers, 3D illimitées). Revenus complémentaires via partenariats marques mobilier. Tarifs publics et clairement affichés.\n\n## Pour qui\nArchitectes d\u0027intérieur et décorateurs cherchant un flux de projets de petite envergure (pièce unique). Non adapté aux architectes HMONP ou MOE : scope exclusivement déco, sans permis de construire ni maîtrise d\u0027œuvre.\n\n## Points forts\nTarification entièrement publique et transparente — rare dans le panel B2C. Modèle clair sans surprise. Partenariats marques mobilier avec remises clients. Approche structurée par pièce facilitant la gestion du temps professionnel.\n\n## Points de vigilance\nRevenus unitaires faibles (90-235€/pièce) pour 1-3 semaines de travail. Scope exclusivement déco intérieure. Partenariats marques peuvent orienter les recommandations vers des produits sponsors. Pas de feedback communauté disponible.",
|
|
||||||
"description_courte": "Plateforme déco par pièce, tarifs publics et clairs (90-235€). Réservée aux architectes d\u0027intérieur — scope exclusivement déco, revenus unitaires faibles pour 1-3 semaines de travail.",
|
|
||||||
"scoring": {
|
|
||||||
"remuneration": "⚠️",
|
|
||||||
"transparence": "✅",
|
|
||||||
"pratiques": "✅",
|
|
||||||
"ecologie": "⚠️",
|
|
||||||
"matching": "⚠️",
|
|
||||||
"tag_global": "sous-reserve",
|
|
||||||
"justification_tag": "Transparence ✅ (tarification publique, claire, affichée) et Pratiques ✅ (pas de concours, scope honnête, modèle sain). Rémunération en ⚠️ car les forfaits 90-235€/pièce génèrent des revenus unitaires faibles pour 1-3 semaines de travail de conception. Scope très limité (déco intérieure uniquement)."
|
|
||||||
},
|
|
||||||
"secteurs_servis": [
|
|
||||||
"architecture-interieure"
|
|
||||||
],
|
|
||||||
"zone_geo": "france-entiere",
|
|
||||||
"cout_entree": "abonnement",
|
|
||||||
"date_creation_fiche": "2026-05-06",
|
|
||||||
"date_derniere_maj": "2026-05-06",
|
|
||||||
"source_donnees": [
|
|
||||||
"https://r.jina.ai/https://pipcke.fr"
|
|
||||||
],
|
|
||||||
"flag_validation_jules": false,
|
|
||||||
"commentaires": [
|
|
||||||
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "moncoachdeco",
|
|
||||||
"nom": "MonCoachDéco",
|
|
||||||
"url": "https://moncoachdeco.com",
|
|
||||||
"type": "b2c-mise-en-relation",
|
|
||||||
"description": "## Présentation\nMarketplace numérique mettant en relation architectes d\u0027intérieur et décorateurs avec des clients. Inscription de base gratuite. Les professionnels reçoivent des leads projets correspondant à leur localisation et compétences, puis choisissent quels projets accepter.\n\n## Modèle économique\nFreemium : profil de base gratuit. Accès aux coordonnées clients payant (achat à la carte ou abonnement illimité). Options additionnelles : nom de domaine personnalisé, logiciel comptable intégré. Tarifs non publiés sur le site principal.\n\n## Pour qui\nArchitectes d\u0027intérieur et décorateurs souhaitant un flux de leads qualifiés dans leur zone géographique. Non adapté aux architectes HMONP ou MOE : scope exclusivement architecture intérieure et décoration, sans maîtrise d\u0027œuvre ni permis de construire.\n\n## Points forts\nLiberté de sélection : le professionnel choisit les projets qu\u0027il accepte. Inscription sans frais initiale. Ciblage géographique et par compétences. Services additionnels (domaine, comptabilité) intégrés en option.\n\n## Points de vigilance\nTarifs non publiés — impossible d\u0027évaluer le rapport coût/bénéfice avant inscription. Coût par lead potentiellement élevé si le taux de conversion est faible. Pas de feedback communauté disponible — qualité des leads inconnue.",
|
|
||||||
"description_courte": "Leads architectes d\u0027intérieur avec liberté de sélection des projets, inscription gratuite. Tarifs d\u0027accès aux coordonnées non publiés — coût réel impossible à évaluer sans inscription.",
|
|
||||||
"scoring": {
|
|
||||||
"remuneration": "⚠️",
|
|
||||||
"transparence": "⚠️",
|
|
||||||
"pratiques": "✅",
|
|
||||||
"ecologie": "⚠️",
|
|
||||||
"matching": "⚠️",
|
|
||||||
"tag_global": "sous-reserve",
|
|
||||||
"justification_tag": "Pratiques ✅ : liberté totale de sélection des projets, pas de mise en concurrence, inscription sans frais initiale. Mais tarifs non publiés (⚠️ Transparence) et absence de feedback communauté rendent impossible l\u0027évaluation du rapport coût/efficacité."
|
|
||||||
},
|
|
||||||
"secteurs_servis": [
|
|
||||||
"architecture-interieure"
|
|
||||||
],
|
|
||||||
"zone_geo": "france-entiere",
|
|
||||||
"cout_entree": "freemium",
|
|
||||||
"date_creation_fiche": "2026-05-06",
|
|
||||||
"date_derniere_maj": "2026-05-06",
|
|
||||||
"source_donnees": [
|
|
||||||
"https://r.jina.ai/https://moncoachdeco.com/plateforme-decorateur-architecte"
|
|
||||||
],
|
|
||||||
"flag_validation_jules": false,
|
|
||||||
"commentaires": [
|
|
||||||
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "eldo-pro",
|
|
||||||
"nom": "Eldo / EldoPro",
|
|
||||||
"url": "https://www.eldo.com",
|
|
||||||
"type": "b2c-mise-en-relation",
|
|
||||||
"description": "## Présentation\nPlateforme d\u0027avis entre voisins et de mise en relation avec des professionnels qualifiés du bâtiment. 1 200 pros qualifiés en France, 8 000+ projets accompagnés. Présence dans 7 grandes métropoles (Toulouse, Paris, Bordeaux, Marseille, Lyon, Montpellier, Lille). Coordonnées transmises uniquement au professionnel choisi par le client.\n\n## Modèle économique\nLead generation pour EldoPro (artisans individuels) et EldoNetwork (réseaux et marques). Tarifs non publiés. L\u0027architecte paie pour les leads reçus selon un modèle à la demande ou abonnement — conditions exactes à confirmer directement.\n\n## Pour qui\nArtisans BTP généralistes en priorité. Les architectes sont absents de la description principale de la plateforme — leur présence est marginale et non valorisée dans l\u0027offre Eldo.\n\n## Points forts\nSystème d\u0027avis entre voisins = leads avec recommandation sociale. Coordonnées transmises uniquement au professionnel choisi (pas de multi-diffusion massive). Présence métropolitaine dense dans 7 grandes villes.\n\n## Points de vigilance\nArchitectes très peu représentés dans l\u0027offre principale. Modèle orienté artisanat BTP, pas architecture de conception ou maîtrise d\u0027œuvre. Tarifs opaques. Pas de feedback communauté disponible pour évaluer la qualité des leads archi.",
|
|
||||||
"description_courte": "Plateforme leads artisans BTP avec recommandations entre voisins. Architectes absents de l\u0027offre principale — inadapté aux missions de conception ou maîtrise d\u0027œuvre.",
|
|
||||||
"scoring": {
|
|
||||||
"remuneration": "⚠️",
|
|
||||||
"transparence": "⚠️",
|
|
||||||
"pratiques": "⚠️",
|
|
||||||
"ecologie": "⚠️",
|
|
||||||
"matching": "⚠️",
|
|
||||||
"tag_global": "sous-reserve",
|
|
||||||
"justification_tag": "Cinq axes en ⚠️ : tarification opaque, architectes marginaux dans l\u0027offre principale, pas de feedback communauté. Seul signal positif : coordonnées transmises uniquement au pro choisi (pas de revente massive). Inadapté comme canal principal pour les architectes."
|
|
||||||
},
|
|
||||||
"secteurs_servis": [
|
|
||||||
"renovation",
|
|
||||||
"transversal"
|
|
||||||
],
|
|
||||||
"zone_geo": "france-entiere",
|
|
||||||
"cout_entree": "lead-paye",
|
|
||||||
"date_creation_fiche": "2026-05-06",
|
|
||||||
"date_derniere_maj": "2026-05-06",
|
|
||||||
"source_donnees": [
|
|
||||||
"https://r.jina.ai/https://www.eldo.com"
|
|
||||||
],
|
|
||||||
"flag_validation_jules": false,
|
|
||||||
"commentaires": [
|
|
||||||
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "france-renov-annuaire",
|
|
||||||
"nom": "France Rénov\u0027 — Annuaire pro",
|
|
||||||
"url": "https://france-renov.gouv.fr/annuaires-professionnels/artisan-rge-architecte",
|
|
||||||
"type": "b2c-mise-en-relation",
|
|
||||||
"description": "## Présentation\nAnnuaire officiel ANAH permettant aux particuliers de trouver des professionnels RGE et architectes référencés pour travaux de rénovation énergétique. Service public entièrement gratuit. Mécanisme anti-fraude intégré. Référence obligatoire pour les chantiers éligibles MaPrimeRénov\u0027.\n\n## Modèle économique\nTotalement gratuit, financé par l\u0027État (ANAH). Aucune commission ni frais pour les architectes référencés. L\u0027inscription nécessite une certification MAR ou qualification RGE pertinente — elle-même conditionnée à des critères professionnels rigoureux.\n\n## Pour qui\nArchitectes certifiés MAR (Mon Accompagnateur Rénov\u0027) ou travaillant sur des projets de rénovation énergétique performante. Très pertinent pour les profils spécialisés énergie. Peu d\u0027intérêt pour les architectes exclusivement orientés neuf ou décoration.\n\n## Points forts\nService public gratuit adossé aux dispositifs MaPrimeRénov\u0027. Crédibilité institutionnelle maximale. Seuls les pros certifiés RGE/MAR référencés — signal qualité pour les clients. Génère des leads ciblés sur la rénovation énergétique.\n\n## Points de vigilance\nAnnuaire passif — c\u0027est le particulier qui recherche, pas un matching actif. Pertinent uniquement pour les architectes certifiés MAR ou RGE. Génération de leads limitée si peu de communication publique sur le dispositif.",
|
|
||||||
"description_courte": "Annuaire officiel ANAH gratuit pour architectes certifiés MAR ou RGE. Leads ciblés rénovation énergétique via MaPrimeRénov\u0027. Peu pertinent pour les profils non certifiés.",
|
|
||||||
"scoring": {
|
|
||||||
"remuneration": "✅",
|
|
||||||
"transparence": "✅",
|
|
||||||
"pratiques": "✅",
|
|
||||||
"ecologie": "✅",
|
|
||||||
"matching": "⚠️",
|
|
||||||
"tag_global": "recommande",
|
|
||||||
"justification_tag": "Quatre axes en ✅ : service public gratuit (ANAH), institutionnel, intégré MaPrimeRénov\u0027, focalisé rénovation énergétique (MAR, RGE). Seul le matching est en ⚠️ car la plateforme est passive et le volume de leads dépend des campagnes de communication publique sur le dispositif."
|
|
||||||
},
|
|
||||||
"secteurs_servis": [
|
|
||||||
"renovation",
|
|
||||||
"mar-conseil"
|
|
||||||
],
|
|
||||||
"zone_geo": "france-entiere",
|
|
||||||
"cout_entree": "gratuit",
|
|
||||||
"date_creation_fiche": "2026-05-06",
|
|
||||||
"date_derniere_maj": "2026-05-06",
|
|
||||||
"source_donnees": [
|
|
||||||
"https://r.jina.ai/https://france-renov.gouv.fr/annuaires-professionnels/artisan-rge-architecte"
|
|
||||||
],
|
|
||||||
"flag_validation_jules": false,
|
|
||||||
"commentaires": [
|
|
||||||
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "boamp",
|
|
||||||
"nom": "BOAMP",
|
|
||||||
"url": "https://www.boamp.fr",
|
|
||||||
"type": "appel-offre-public",
|
|
||||||
"description": "## Présentation\nBulletin Officiel des Annonces des Marchés Publics — référence institutionnelle pour tous les marchés publics formels français et européens. Géré directement par l\u0027État. Publie les avis publics à la concurrence (AAPC), avis de concession et avis d\u0027attribution.\n\n## Modèle économique\nService public entièrement gratuit. Veille personnalisée jusqu\u0027à 10 alertes configurables. Notification quotidienne des nouveaux avis. Accès aux DCE possible. Aucun frais pour les entreprises candidates, quel que soit le volume d\u0027AO consultés.\n\n## Pour qui\nTous les architectes et bureaux d\u0027études souhaitant répondre à des marchés publics de maîtrise d\u0027œuvre. Source primaire officielle — indispensable pour toute démarche sérieuse de réponse aux AO publics. Complémentaire aux agrégateurs spécialisés.\n\n## Points forts\nSource officielle et exhaustive de tous les marchés publics formels. Totalement gratuit. Alertes email personnalisées (10 profils max). Accès aux DCE directement. Référence légale — toute publication y est obligatoire.\n\n## Points de vigilance\nInterface moins ergonomique que les agrégateurs spécialisés (AppelArchi, Instao). Pas de résumés IA ni de filtres avancés par profil archi. Nécessite une veille active ou des alertes précises pour être efficace.",
|
|
||||||
"description_courte": "Source officielle gratuite de tous les marchés publics français. Interface brute — à coupler avec un agrégateur spécialisé (AppelArchi, Instao) pour une veille efficace adaptée aux profils archi.",
|
|
||||||
"scoring": {
|
|
||||||
"remuneration": null,
|
|
||||||
"transparence": "✅",
|
|
||||||
"pratiques": null,
|
|
||||||
"ecologie": null,
|
|
||||||
"matching": "✅",
|
|
||||||
"tag_global": "recommande",
|
|
||||||
"justification_tag": "Scoring simplifié 2 axes (plateforme appels d\u0027offres publics). Source officielle de l\u0027État : transparence totale (✅) et couverture exhaustive de tous les marchés formels (✅ Matching)."
|
|
||||||
},
|
|
||||||
"secteurs_servis": [
|
|
||||||
"urbanisme",
|
|
||||||
"mar-conseil",
|
|
||||||
"transversal"
|
|
||||||
],
|
|
||||||
"zone_geo": "france-entiere",
|
|
||||||
"cout_entree": "freemium",
|
|
||||||
"date_creation_fiche": "2026-05-06",
|
|
||||||
"date_derniere_maj": "2026-05-06",
|
|
||||||
"source_donnees": [
|
|
||||||
"https://r.jina.ai/https://www.boamp.fr"
|
|
||||||
],
|
|
||||||
"flag_validation_jules": false,
|
|
||||||
"commentaires": [
|
|
||||||
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "e-marchespublics",
|
|
||||||
"nom": "E-marchespublics.com",
|
|
||||||
"url": "https://www.e-marchespublics.com",
|
|
||||||
"type": "appel-offre-public",
|
|
||||||
"description": "## Présentation\nPlateforme française d\u0027agrégation marchés publics permettant découverte d\u0027AO et soumission de candidatures électroniques. Agrège BOAMP, JOUE et sources régionales. 991 762 entreprises inscrites. 58,8M documents téléchargés. 600 000+ opportunités annuelles. Réponse dématérialisée sécurisée en 5 minutes.\n\n## Modèle économique\nFreemium : compte gratuit avec recherche, alertes email quotidiennes et dépôt de candidatures. Fonctionnalités avancées payantes (monitoring détaillé, complétion auto formulaires, filtres avancés). Accès complet aux fonctions essentielles sans engagement financier.\n\n## Pour qui\nArchitectes et bureaux d\u0027études souhaitant une veille AO mutualisée et une soumission dématérialisée simplifiée. Marchés MOE et architecture confirmés dans les résultats (ex: Institut Bergonie, Logeal Immobilière).\n\n## Points forts\nAgrégation multi-sources (BOAMP + JOUE + régionaux). Dépôt de candidature dématérialisé intégré. Large base d\u0027entreprises inscrites (991k). Gratuit pour les fonctions essentielles. Marchés MOE archi confirmés.\n\n## Points de vigilance\nFonctionnalités avancées payantes sans tarifs précisés. Volume très large (600k+ AO/an) nécessitant des filtres précis pour isoler les marchés MOE archi pertinents.",
|
|
||||||
"description_courte": "Agrégateur multi-sources marchés publics (BOAMP + JOUE + régionaux) avec soumission dématérialisée gratuite. Marchés MOE archi confirmés — fonctions avancées payantes sans tarifs affichés.",
|
|
||||||
"scoring": {
|
|
||||||
"remuneration": null,
|
|
||||||
"transparence": "✅",
|
|
||||||
"pratiques": null,
|
|
||||||
"ecologie": null,
|
|
||||||
"matching": "✅",
|
|
||||||
"tag_global": "recommande",
|
|
||||||
"justification_tag": "Scoring simplifié 2 axes (plateforme appels d\u0027offres publics). Modèle freemium clair avec accès gratuit documenté (✅ Transparence) et agrégation multi-sources avec marchés MOE archi confirmés (✅ Matching)."
|
|
||||||
},
|
|
||||||
"secteurs_servis": [
|
|
||||||
"transversal"
|
|
||||||
],
|
|
||||||
"zone_geo": "france-entiere",
|
|
||||||
"cout_entree": "freemium",
|
|
||||||
"date_creation_fiche": "2026-05-06",
|
|
||||||
"date_derniere_maj": "2026-05-06",
|
|
||||||
"source_donnees": [
|
|
||||||
"https://r.jina.ai/https://www.e-marchespublics.com"
|
|
||||||
],
|
|
||||||
"flag_validation_jules": false,
|
|
||||||
"commentaires": [
|
|
||||||
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "centrale-des-marches",
|
|
||||||
"nom": "Centrale des Marchés",
|
|
||||||
"url": "https://centraledesmarches.com",
|
|
||||||
"type": "appel-offre-public",
|
|
||||||
"description": "## Présentation\nPlateforme de veille marchés publics et privés lancée en 2021 par Medialex. Agrège BOAMP, JOUE, presse régionale et marchés privés. 16 005 avis actifs, 48 857 acheteurs publics identifiés. 1 515 opportunités listées en catégorie architecture/construction/ingénierie. Formations marchés publics disponibles.\n\n## Modèle économique\nFreemium : alertes email gratuites pour les entreprises candidates. Solutions payantes de dématérialisation pour les acheteurs publics. Tarifs d\u0027abonnement pour les fonctions avancées non précisés sur la homepage.\n\n## Pour qui\nArchitectes souhaitant couvrir à la fois les marchés publics et privés dans une seule interface. La double couverture est un différenciateur intéressant pour les profils cherchant un flux diversifié de projets.\n\n## Points forts\nDouble couverture marchés publics + privés — rare dans le panel. 1 515 opportunités archi/construction. Alertes email gratuites. Formations marchés publics disponibles — valeur ajoutée pour les profils débutants en AO.\n\n## Points de vigilance\nTarifs abonnement pour les fonctions avancées non précisés. Plateforme lancée en 2021 — moins mature que BOAMP ou e-marchespublics. Marchés privés : qualité et fiabilité des données à confirmer.",
|
|
||||||
"description_courte": "Veille marchés publics + privés avec alertes email gratuites. Double couverture différenciante mais tarifs abonnement opaques et plateforme jeune (lancée 2021).",
|
|
||||||
"scoring": {
|
|
||||||
"remuneration": null,
|
|
||||||
"transparence": "⚠️",
|
|
||||||
"pratiques": null,
|
|
||||||
"ecologie": null,
|
|
||||||
"matching": "✅",
|
|
||||||
"tag_global": "sous-reserve",
|
|
||||||
"justification_tag": "Scoring simplifié 2 axes (plateforme appels d\u0027offres publics). Matching ✅ (double couverture public+privé, 1 515 opportunités archi). Transparence ⚠️ : tarifs des fonctions avancées non publiés sur la homepage. Configuration 1✅ + 1⚠️ → sous-réserve."
|
|
||||||
},
|
|
||||||
"secteurs_servis": [
|
|
||||||
"transversal"
|
|
||||||
],
|
|
||||||
"zone_geo": "france-entiere",
|
|
||||||
"cout_entree": "freemium",
|
|
||||||
"date_creation_fiche": "2026-05-06",
|
|
||||||
"date_derniere_maj": "2026-05-06",
|
|
||||||
"source_donnees": [
|
|
||||||
"https://r.jina.ai/https://centraledesmarches.com"
|
|
||||||
],
|
|
||||||
"flag_validation_jules": false,
|
|
||||||
"commentaires": [
|
|
||||||
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "appelarchi",
|
|
||||||
"nom": "AppelArchi",
|
|
||||||
"url": "https://appelarchi.fr",
|
|
||||||
"type": "appel-offre-public",
|
|
||||||
"description": "## Présentation\nPlateforme spécialisée pour les professionnels de l\u0027architecture. Agrège BOAMP, JOUE, TED et sources régionales. 300+ opportunités analysées quotidiennement. Filtres avancés par profil archi, suivi des lauréats, résumés IA. Inclut DOM-TOM. Conçue spécifiquement pour les cabinets d\u0027architecture.\n\n## Modèle économique\nAccès conditionnel suggéré par le CTA \u0027accéder à la plateforme\u0027. Modèle exact (gratuit/payant/abonnement) non précisé sur la homepage. Tarification à confirmer après inscription. Présumé abonnement compte tenu de la spécialisation du service.\n\n## Pour qui\nCabinets d\u0027architecture et architectes indépendants souhaitant une veille AO spécialisée avec valeur ajoutée IA. La spécialisation sur le profil archi est un avantage significatif face aux agrégateurs généralistes.\n\n## Points forts\nSeule plateforme du panel 100% dédiée aux marchés archi. Résumés IA des AO. Suivi des lauréats. Filtres avancés par profil. Couverture DOM-TOM. 300+ opportunités analysées quotidiennement.\n\n## Points de vigilance\nTarification entièrement opaque avant inscription — risque d\u0027engagement sans visibilité sur les coûts. Service présumé payant mais aucune information tarifaire publique disponible.",
|
|
||||||
"description_courte": "Plateforme 100% dédiée aux marchés publics archi, résumés IA et suivi des lauréats. Tarification entièrement opaque avant inscription — à tester avant tout engagement financier.",
|
|
||||||
"scoring": {
|
|
||||||
"remuneration": null,
|
|
||||||
"transparence": "⚠️",
|
|
||||||
"pratiques": null,
|
|
||||||
"ecologie": null,
|
|
||||||
"matching": "✅",
|
|
||||||
"tag_global": "sous-reserve",
|
|
||||||
"justification_tag": "Scoring simplifié 2 axes (plateforme appels d\u0027offres publics). Matching ✅ : spécialisation archi complète, résumés IA, suivi lauréats — outil différenciant dans le panel. Transparence ⚠️ : tarification entièrement opaque avant inscription. Configuration 1✅ + 1⚠️ → sous-réserve."
|
|
||||||
},
|
|
||||||
"secteurs_servis": [
|
|
||||||
"urbanisme",
|
|
||||||
"mar-conseil",
|
|
||||||
"transversal"
|
|
||||||
],
|
|
||||||
"zone_geo": "france-entiere",
|
|
||||||
"cout_entree": "abonnement",
|
|
||||||
"date_creation_fiche": "2026-05-06",
|
|
||||||
"date_derniere_maj": "2026-05-06",
|
|
||||||
"source_donnees": [
|
|
||||||
"https://r.jina.ai/https://appelarchi.fr"
|
|
||||||
],
|
|
||||||
"flag_validation_jules": false,
|
|
||||||
"commentaires": [
|
|
||||||
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "akkel",
|
|
||||||
"nom": "Akkel",
|
|
||||||
"url": "https://www.akkel.fr",
|
|
||||||
"type": "appel-offre-public",
|
|
||||||
"description": "## Présentation\nPlateforme de veille automatisée pour marchés publics. Recommandations personnalisées basées sur l\u0027activité réelle de l\u0027entreprise, sans configuration initiale manuelle. 46 000+ notices 2024, 12 157 AO actifs, 95 155 notices 2025. Couvre les publications officielles complètes, mises à jour quotidiennement.\n\n## Modèle économique\nTrial gratuit de 21 jours avec accès à toutes les fonctionnalités. Tarifs post-trial non affichés publiquement — à confirmer après la période d\u0027essai. Modèle présumé abonnement.\n\n## Pour qui\nArchitectes et bureaux d\u0027études souhaitant une veille entièrement automatisée sans configuration manuelle complexe. L\u0027algorithme de recommandation personnalisée basé sur l\u0027historique d\u0027activité est un différenciateur pour les profils expérimentés en marchés publics.\n\n## Points forts\nRecommandations personnalisées automatiques (aucune configuration manuelle). Trial 21 jours gratuit avec accès complet. Volume élevé : 95k+ notices 2025. Couverture complète des publications officielles.\n\n## Points de vigilance\nTarifs post-trial non affichés — impossible d\u0027anticiper le coût avant la fin de l\u0027essai. Plateforme moins connue que BOAMP ou e-marchespublics — maturité à confirmer. Trial gratuit peut créer un biais d\u0027engagement.",
|
|
||||||
"description_courte": "Veille marchés publics automatisée avec recommandations personnalisées, trial 21 jours gratuit. Tarifs post-trial opaques — à comparer avec d\u0027autres agrégateurs avant engagement.",
|
|
||||||
"scoring": {
|
|
||||||
"remuneration": null,
|
|
||||||
"transparence": "⚠️",
|
|
||||||
"pratiques": null,
|
|
||||||
"ecologie": null,
|
|
||||||
"matching": "✅",
|
|
||||||
"tag_global": "sous-reserve",
|
|
||||||
"justification_tag": "Scoring simplifié 2 axes (plateforme appels d\u0027offres publics). Matching ✅ : recommandations personnalisées automatiques, 95k+ notices 2025. Transparence ⚠️ : tarifs post-trial non publiés — le trial gratuit ne permet pas d\u0027anticiper le coût réel. Configuration 1✅ + 1⚠️ → sous-réserve."
|
|
||||||
},
|
|
||||||
"secteurs_servis": [
|
|
||||||
"transversal"
|
|
||||||
],
|
|
||||||
"zone_geo": "france-entiere",
|
|
||||||
"cout_entree": "freemium",
|
|
||||||
"date_creation_fiche": "2026-05-06",
|
|
||||||
"date_derniere_maj": "2026-05-06",
|
|
||||||
"source_donnees": [
|
|
||||||
"https://r.jina.ai/https://www.akkel.fr"
|
|
||||||
],
|
|
||||||
"flag_validation_jules": false,
|
|
||||||
"commentaires": [
|
|
||||||
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "marches-publics-gouv",
|
|
||||||
"nom": "Marchés-publics.gouv.fr (PLACE)",
|
|
||||||
"url": "https://www.marches-publics.gouv.fr",
|
|
||||||
"type": "appel-offre-public",
|
|
||||||
"description": "## Présentation\nPlateforme officielle de l\u0027État français pour les marchés publics et leur dématérialisation. Agrège les marchés de l\u0027ensemble des entités publiques françaises. Référence institutionnelle pour la soumission de candidatures. Complémentaire au BOAMP pour la réponse aux AO.\n\n## Modèle économique\nAccès totalement gratuit pour les entreprises candidates. Service public financé par l\u0027État. Aucune contrainte financière pour la consultation des AO ou la soumission de candidatures. Pas d\u0027abonnement, pas de frais de dossier.\n\n## Pour qui\nTous les architectes et bureaux d\u0027études répondant à des marchés publics. Référence institutionnelle incontournable pour la soumission de candidatures dématérialisées — complémentaire à la veille sur BOAMP ou les agrégateurs spécialisés.\n\n## Points forts\nService public officiel, gratuit, institutionnel. Référence légale pour la dématérialisation des candidatures. Couverture de toutes les entités publiques françaises. Complémentaire aux outils de veille.\n\n## Points de vigilance\nURL principale instable (erreur 400 signalée lors du scraping T1). Interface à prendre en main — moins ergonomique que les agrégateurs privés. Outil de soumission avant tout, pas de veille proactive.",
|
|
||||||
"description_courte": "Plateforme officielle gratuite de l\u0027État pour la soumission dématérialisée de candidatures AO. Outil institutionnel de référence — à coupler avec un agrégateur pour la veille proactive.",
|
|
||||||
"scoring": {
|
|
||||||
"remuneration": null,
|
|
||||||
"transparence": "✅",
|
|
||||||
"pratiques": null,
|
|
||||||
"ecologie": null,
|
|
||||||
"matching": "✅",
|
|
||||||
"tag_global": "recommande",
|
|
||||||
"justification_tag": "Scoring simplifié 2 axes (plateforme appels d\u0027offres publics). Service officiel de l\u0027État entièrement gratuit (✅ Transparence) et couverture institutionnelle de toutes les entités publiques françaises (✅ Matching). URL instable signalée en T1 — à surveiller."
|
|
||||||
},
|
|
||||||
"secteurs_servis": [
|
|
||||||
"transversal"
|
|
||||||
],
|
|
||||||
"zone_geo": "france-entiere",
|
|
||||||
"cout_entree": "gratuit",
|
|
||||||
"date_creation_fiche": "2026-05-06",
|
|
||||||
"date_derniere_maj": "2026-05-06",
|
|
||||||
"source_donnees": [
|
|
||||||
|
|
||||||
],
|
|
||||||
"flag_validation_jules": false,
|
|
||||||
"commentaires": [
|
|
||||||
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "instao",
|
|
||||||
"nom": "Instao",
|
|
||||||
"url": "https://www.instao.fr",
|
|
||||||
"type": "appel-offre-public",
|
|
||||||
"description": "## Présentation\nPlateforme IA de veille marchés publics français, en beta en 2026. Agrège BOAMP, PLACE, e-marchespublics, Maximilien, Mégalis Bretagne et sources régionales. Catégorie Maîtrise d\u0027œuvre explicitement couverte. Fiches synthétiques par marché, alertes email, téléchargement DCE en 1 clic. Module IA pour rédiger mémoires techniques (DC1, DC2).\n\n## Modèle économique\nPlan Veille Automatisée : 89€ HT/mois, engagement mensuel, 1 utilisateur, 1 activité de veille. Plan PME : prix à l\u0027utilisation, utilisateurs illimités, multi-activités. Plan Entreprise : sur devis. Module Réponse IA : crédits (tarif non affiché). Tarification principale publique et claire.\n\n## Pour qui\nArchitectes indépendants souhaitant une veille MOE automatisée avec aide à la rédaction des mémoires techniques. Le module IA de réponse est un différenciateur fort pour les profils peu habitués aux marchés publics ou manquant de temps pour rédiger les dossiers.\n\n## Points forts\nCatégorie MOE archi explicitement couverte. Module IA aide à la rédaction (mémoires, DC1, DC2) — unique dans le panel. Tarification principale publique (89€/mois). Engagement mensuel sans engagement annuel forcé.\n\n## Points de vigilance\nService en beta (2026) — fiabilité et couverture à confirmer sur la durée. Tarif module Réponse IA non affiché (crédits). 89€/mois représente un investissement significatif pour un indépendant en démarrage.",
|
|
||||||
"description_courte": "Veille marchés publics IA spécialisée MOE avec module rédaction mémoires DC1/DC2. 89€ HT/mois, engagement mensuel. En beta 2026 — prometteuse mais fiabilité à confirmer.",
|
|
||||||
"scoring": {
|
|
||||||
"remuneration": null,
|
|
||||||
"transparence": "✅",
|
|
||||||
"pratiques": null,
|
|
||||||
"ecologie": null,
|
|
||||||
"matching": "✅",
|
|
||||||
"tag_global": "recommande",
|
|
||||||
"justification_tag": "Scoring simplifié 2 axes (plateforme appels d\u0027offres publics). Tarification principale publique (89€/mois affiché sur /pricing, ✅ Transparence) et catégorie MOE explicitement couverte avec module IA rédaction unique dans le panel (✅ Matching). En beta — à surveiller."
|
|
||||||
},
|
|
||||||
"secteurs_servis": [
|
|
||||||
"mar-conseil",
|
|
||||||
"transversal"
|
|
||||||
],
|
|
||||||
"zone_geo": "france-entiere",
|
|
||||||
"cout_entree": "abonnement",
|
|
||||||
"date_creation_fiche": "2026-05-06",
|
|
||||||
"date_derniere_maj": "2026-05-06",
|
|
||||||
"source_donnees": [
|
|
||||||
"https://www.instao.fr/pricing"
|
|
||||||
],
|
|
||||||
"flag_validation_jules": false,
|
|
||||||
"commentaires": [
|
|
||||||
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "francemarches",
|
|
||||||
"nom": "FranceMarchés",
|
|
||||||
"url": "https://www.francemarches.com/appels-offre/maitrise-oeuvre",
|
|
||||||
"type": "appel-offre-public",
|
|
||||||
"description": "## Présentation\nPortail d\u0027appels d\u0027offres publics agrégant les publications de la presse régionale (Ouest-France, Voix du Nord, Est Républicain, Le Dauphiné, La Montagne...) en plus du BOAMP et des sources officielles. 3 016 AO maîtrise d\u0027œuvre en cours au 06/05/2026. 108 000+ abonnés. CGU accessibles sur /cgu.\n\n## Modèle économique\nAccès aux annonces entièrement gratuit. Alertes email gratuites. CGU disponibles et accessibles sur /cgu. Modèle économique basé sur le financement de la presse régionale partenaire. Aucun frais d\u0027inscription ni d\u0027abonnement pour les entreprises candidates.\n\n## Pour qui\nArchitectes cherchant des AO de maîtrise d\u0027œuvre sur tout le territoire, y compris les zones moins couvertes par BOAMP seul. La couverture presse régionale est un complément précieux pour les marchés locaux et régionaux.\n\n## Points forts\nCouverture presse régionale unique dans le panel — accès aux marchés locaux non publiés sur BOAMP uniquement. 3 016 AO MOE actifs confirmés. Entièrement gratuit. 108 000+ abonnés (forte adoption). CGU transparentes et accessibles.\n\n## Points de vigilance\nRésultats MOE très variés (logements, réhabilitation, aménagement urbain, infrastructures) — filtrage nécessaire pour isoler les missions archi résidentielle. Données issues partiellement de la presse régionale — moins uniformes que les sources officielles.",
|
|
||||||
"description_courte": "Portail gratuit agrégeant AO publics via presse régionale + BOAMP. 3 016 AO maîtrise d\u0027œuvre actifs. Couverture régionale unique — filtrage nécessaire parmi des résultats MOE variés.",
|
|
||||||
"scoring": {
|
|
||||||
"remuneration": null,
|
|
||||||
"transparence": "✅",
|
|
||||||
"pratiques": null,
|
|
||||||
"ecologie": null,
|
|
||||||
"matching": "✅",
|
|
||||||
"tag_global": "recommande",
|
|
||||||
"justification_tag": "Scoring simplifié 2 axes (plateforme appels d\u0027offres publics). Accès gratuit avec CGU accessibles sur /cgu (✅ Transparence) et 3 016 AO MOE actifs via couverture presse régionale unique dans le panel (✅ Matching)."
|
|
||||||
},
|
|
||||||
"secteurs_servis": [
|
|
||||||
"transversal"
|
|
||||||
],
|
|
||||||
"zone_geo": "france-entiere",
|
|
||||||
"cout_entree": "freemium",
|
|
||||||
"date_creation_fiche": "2026-05-06",
|
|
||||||
"date_derniere_maj": "2026-05-06",
|
|
||||||
"source_donnees": [
|
|
||||||
"https://www.francemarches.com/appels-offre/maitrise-oeuvre",
|
|
||||||
"https://www.francemarches.com/cgu"
|
|
||||||
],
|
|
||||||
"flag_validation_jules": false,
|
|
||||||
"commentaires": [
|
|
||||||
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
[]
|
||||||
@@ -0,0 +1,834 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"nom": "Rotor",
|
||||||
|
"pays": "BE",
|
||||||
|
"ville": "Bruxelles",
|
||||||
|
"type": "cooperative",
|
||||||
|
"url": "https://rotordb.org",
|
||||||
|
"lat": 50.8503,
|
||||||
|
"lng": 4.3517,
|
||||||
|
"description": "Pionnier européen du réemploi. Filiale RotorDC opérationnelle depuis 2016, showroom + entrepôt à Vilvorde. Publications de référence sur l'économie circulaire du bâtiment, expositions internationales.",
|
||||||
|
"criteres": [1, 2, 5, 6, 8],
|
||||||
|
"score": 5,
|
||||||
|
"tags": ["réemploi", "recherche", "publications", "coopérative"],
|
||||||
|
"source": "seed Jules + scrape WBDM",
|
||||||
|
"passe": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"nom": "Assemble Studio",
|
||||||
|
"pays": "UK",
|
||||||
|
"ville": "Londres",
|
||||||
|
"type": "collectif",
|
||||||
|
"url": "https://assemblestudio.co.uk",
|
||||||
|
"lat": 51.5074,
|
||||||
|
"lng": -0.1278,
|
||||||
|
"description": "Turner Prize 2015. Travaille à la frontière architecture/art/design. Granby Workshop, Yardhouse, Sugarhouse Studios. Pratique non-hiérarchique, autoconstruction, fabrication interne.",
|
||||||
|
"criteres": [1, 3, 4, 6, 8],
|
||||||
|
"score": 5,
|
||||||
|
"tags": ["autoconstruction", "art", "social", "collectif horizontal"],
|
||||||
|
"source": "seed Jules",
|
||||||
|
"passe": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"nom": "Forensic Architecture",
|
||||||
|
"pays": "UK",
|
||||||
|
"ville": "Londres",
|
||||||
|
"type": "recherche",
|
||||||
|
"url": "https://forensic-architecture.org",
|
||||||
|
"lat": 51.4742,
|
||||||
|
"lng": -0.0176,
|
||||||
|
"description": "Investigation architecturale autour de violations des droits humains et environnementaux. Contre-expertise vidéo/spatiale open-source. Balise politique du courant régénératif, transmission ouverte.",
|
||||||
|
"criteres": [5, 8],
|
||||||
|
"score": 2,
|
||||||
|
"tags": ["enquête", "politique", "contre-expertise", "open-source"],
|
||||||
|
"source": "seed Jules",
|
||||||
|
"passe": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 4,
|
||||||
|
"nom": "Encore Heureux",
|
||||||
|
"pays": "FR",
|
||||||
|
"ville": "Paris",
|
||||||
|
"type": "agence",
|
||||||
|
"url": "https://encoreheureux.org",
|
||||||
|
"lat": 48.8566,
|
||||||
|
"lng": 2.3522,
|
||||||
|
"description": "Pavillon France Biennale Venise 2018 'Lieux Infinis'. Projets emblématiques sur le réemploi (Pavillon Circulaire, COP21). Manifeste, livres, posture publique forte.",
|
||||||
|
"criteres": [1, 3, 5, 8],
|
||||||
|
"score": 4,
|
||||||
|
"tags": ["réemploi", "manifeste", "Biennale", "publications"],
|
||||||
|
"source": "seed Jules",
|
||||||
|
"passe": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 5,
|
||||||
|
"nom": "Construire (Patrick Bouchain)",
|
||||||
|
"pays": "FR",
|
||||||
|
"ville": "Paris",
|
||||||
|
"type": "agence",
|
||||||
|
"url": "https://construire-architectes.com",
|
||||||
|
"lat": 48.8566,
|
||||||
|
"lng": 2.3522,
|
||||||
|
"description": "Maître d'œuvre du 'permis de faire'. Lieu Unique, Le Channel, friches culturelles. MOA habitante, pédagogie de chantier, transmission. Figure tutélaire du courant.",
|
||||||
|
"criteres": [3, 4, 5, 8],
|
||||||
|
"score": 4,
|
||||||
|
"tags": ["permis de faire", "MOA habitante", "friches", "transmission"],
|
||||||
|
"source": "seed Jules",
|
||||||
|
"passe": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 6,
|
||||||
|
"nom": "Boidot+Robin",
|
||||||
|
"pays": "FR",
|
||||||
|
"ville": "Sézanne",
|
||||||
|
"type": "agence",
|
||||||
|
"url": "https://julienboidot.fr",
|
||||||
|
"lat": 48.7253,
|
||||||
|
"lng": 3.7211,
|
||||||
|
"description": "Architecture rurale champenoise, terre crue, bois local, sobriété matérielle radicale. Maisons individuelles et équipements publics. Engagement territorial fort, déshérence rurale.",
|
||||||
|
"criteres": [1, 2, 3, 8],
|
||||||
|
"score": 4,
|
||||||
|
"tags": ["terre crue", "rural", "low-tech", "filières courtes"],
|
||||||
|
"source": "seed Jules",
|
||||||
|
"passe": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 7,
|
||||||
|
"nom": "Tepop",
|
||||||
|
"pays": "FR",
|
||||||
|
"ville": "Bordeaux",
|
||||||
|
"type": "cooperative",
|
||||||
|
"url": "https://tepop.fr",
|
||||||
|
"lat": 44.8378,
|
||||||
|
"lng": -0.5792,
|
||||||
|
"description": "Coopérative d'architecture et urbanisme. Démarches participatives, projets péri-urbains, modèle SCOP. Travaille sur la commande publique et habitat groupé.",
|
||||||
|
"criteres": [3, 4, 6, 8],
|
||||||
|
"score": 4,
|
||||||
|
"tags": ["SCOP", "participatif", "périurbain", "coopérative"],
|
||||||
|
"source": "seed Jules",
|
||||||
|
"passe": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 8,
|
||||||
|
"nom": "Architecture & Précarités",
|
||||||
|
"pays": "FR",
|
||||||
|
"ville": "Paris",
|
||||||
|
"type": "reseau",
|
||||||
|
"url": "https://architecture-precarites.fr",
|
||||||
|
"lat": 48.8566,
|
||||||
|
"lng": 2.3522,
|
||||||
|
"description": "Réseau d'architectes engagés sur les questions de précarité, mal-logement, hospitalité. Recherche-action, publications, plaidoyer. Plateforme lancée 2022 par ENSA Paris-Belleville.",
|
||||||
|
"criteres": [4, 5, 8],
|
||||||
|
"score": 3,
|
||||||
|
"tags": ["précarités", "recherche-action", "hospitalité"],
|
||||||
|
"source": "seed Jules",
|
||||||
|
"passe": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 9,
|
||||||
|
"nom": "Agence ATM",
|
||||||
|
"pays": "FR",
|
||||||
|
"ville": "Bagnolet",
|
||||||
|
"type": "agence",
|
||||||
|
"url": "https://agence-atm.com",
|
||||||
|
"lat": 48.8694,
|
||||||
|
"lng": 2.4200,
|
||||||
|
"description": "Architecture en terre, paille, bois. Chantiers-école, transmission. Projets en Île-de-France et province. Pratique low-tech assumée.",
|
||||||
|
"criteres": [1, 2, 3, 4],
|
||||||
|
"score": 4,
|
||||||
|
"tags": ["terre", "paille", "chantier-école", "low-tech"],
|
||||||
|
"source": "seed Jules",
|
||||||
|
"passe": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 10,
|
||||||
|
"nom": "Frugalité Heureuse & Créative",
|
||||||
|
"pays": "FR",
|
||||||
|
"ville": "Paris",
|
||||||
|
"type": "mouvement",
|
||||||
|
"url": "https://frugalite.org",
|
||||||
|
"lat": 48.8566,
|
||||||
|
"lng": 2.3522,
|
||||||
|
"description": "Mouvement initié par Madec, Lefèvre, Rollet (2018). Manifeste signé par des milliers d'architectes. Expositions, publications, plaidoyer. Référence cardinale du courant frugal en France.",
|
||||||
|
"criteres": [1, 3, 5, 8],
|
||||||
|
"score": 4,
|
||||||
|
"tags": ["manifeste", "sobriété", "low-tech", "mouvement"],
|
||||||
|
"source": "seed Jules",
|
||||||
|
"passe": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 11,
|
||||||
|
"nom": "Atelier Belenfant Daubas",
|
||||||
|
"pays": "FR",
|
||||||
|
"ville": "Vertou",
|
||||||
|
"type": "agence",
|
||||||
|
"url": "https://atelierbelenfantdaubas.org",
|
||||||
|
"lat": 47.1667,
|
||||||
|
"lng": -1.4667,
|
||||||
|
"description": "Agence ligérienne. Bois local, paille, éco-construction sur équipements publics (écoles, salles). Pratique discrète mais référence du courant biosourcé Ouest.",
|
||||||
|
"criteres": [1, 2, 3],
|
||||||
|
"score": 3,
|
||||||
|
"tags": ["bois", "paille", "écoles", "Ouest"],
|
||||||
|
"source": "WebSearch BE",
|
||||||
|
"passe": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 12,
|
||||||
|
"nom": "Réempro / Opalis",
|
||||||
|
"pays": "BE",
|
||||||
|
"ville": "Bruxelles",
|
||||||
|
"type": "plateforme",
|
||||||
|
"url": "https://opalis.eu",
|
||||||
|
"lat": 50.8503,
|
||||||
|
"lng": 4.3517,
|
||||||
|
"description": "Plateforme de référence pour le réemploi (revendeurs, fiches matériaux). Issue de la mouvance Rotor. Service complémentaire d'agence et outil documentaire ouvert.",
|
||||||
|
"criteres": [1, 2, 8],
|
||||||
|
"score": 3,
|
||||||
|
"tags": ["réemploi", "base de données", "open"],
|
||||||
|
"source": "WebSearch",
|
||||||
|
"passe": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 13,
|
||||||
|
"nom": "BC Architects & Studies",
|
||||||
|
"pays": "BE",
|
||||||
|
"ville": "Bruxelles",
|
||||||
|
"type": "agence",
|
||||||
|
"url": "https://bc-as.org",
|
||||||
|
"lat": 50.8503,
|
||||||
|
"lng": 4.3517,
|
||||||
|
"description": "Architecture en terre crue, BTC, transmission via BC Studies (formation). Projets en Belgique, France, Maroc, Burundi. Une des références européennes terre.",
|
||||||
|
"criteres": [1, 2, 3, 8],
|
||||||
|
"score": 4,
|
||||||
|
"tags": ["terre crue", "BTC", "formation", "international"],
|
||||||
|
"source": "connaissance acteur",
|
||||||
|
"passe": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 14,
|
||||||
|
"nom": "Lacol Arquitectura Cooperativa",
|
||||||
|
"pays": "ES",
|
||||||
|
"ville": "Barcelone",
|
||||||
|
"type": "cooperative",
|
||||||
|
"url": "https://lacol.coop",
|
||||||
|
"lat": 41.3851,
|
||||||
|
"lng": 2.1734,
|
||||||
|
"description": "Coopérative fondée 2009 (Sants). La Borda : plus grand bâtiment résidentiel bois d'Espagne à sa livraison. Modèle horizontal, transformation sociale via architecture, habitat coopératif.",
|
||||||
|
"criteres": [3, 4, 6, 8],
|
||||||
|
"score": 4,
|
||||||
|
"tags": ["SCOP", "La Borda", "habitat coopératif", "Barcelone"],
|
||||||
|
"source": "WebSearch",
|
||||||
|
"passe": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 15,
|
||||||
|
"nom": "Recetas Urbanas",
|
||||||
|
"pays": "ES",
|
||||||
|
"ville": "Séville",
|
||||||
|
"type": "collectif",
|
||||||
|
"url": "https://recetasurbanas.net",
|
||||||
|
"lat": 37.3891,
|
||||||
|
"lng": -5.9845,
|
||||||
|
"description": "'Recettes urbaines' : méthodologies open-source pour autoconstruire dans les interstices légaux. Hack urbain, droit comme matériau. Cirugeda figure cardinale architecture sociale espagnole.",
|
||||||
|
"criteres": [4, 5, 6, 8],
|
||||||
|
"score": 4,
|
||||||
|
"tags": ["autoconstruction", "droit", "open-source", "hack"],
|
||||||
|
"source": "WebSearch",
|
||||||
|
"passe": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 16,
|
||||||
|
"nom": "Die Baupiloten",
|
||||||
|
"pays": "DE",
|
||||||
|
"ville": "Berlin",
|
||||||
|
"type": "agence",
|
||||||
|
"url": "https://baupiloten.com",
|
||||||
|
"lat": 52.5200,
|
||||||
|
"lng": 13.4050,
|
||||||
|
"description": "Agence pédagogique (Susanne Hofmann + TU Berlin). Étudiants impliqués dans projets réels (écoles, logement). Méthodologie participative avec usagers, notamment enfants.",
|
||||||
|
"criteres": [4, 8],
|
||||||
|
"score": 2,
|
||||||
|
"tags": ["participation", "écoles", "pédagogie", "université"],
|
||||||
|
"source": "WebSearch",
|
||||||
|
"passe": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 17,
|
||||||
|
"nom": "Baubotanik",
|
||||||
|
"pays": "DE",
|
||||||
|
"ville": "Munich",
|
||||||
|
"type": "recherche",
|
||||||
|
"url": "https://baubotanik.org",
|
||||||
|
"lat": 48.1351,
|
||||||
|
"lng": 11.5820,
|
||||||
|
"description": "Architecture vivante : structures construites par interaction technique + croissance végétale. Recherche fondamentale + démonstrateurs. Champ unique en Europe sur l'intégration vivant/structure.",
|
||||||
|
"criteres": [1, 7, 8],
|
||||||
|
"score": 3,
|
||||||
|
"tags": ["architecture vivante", "végétal", "recherche"],
|
||||||
|
"source": "WebSearch",
|
||||||
|
"passe": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 18,
|
||||||
|
"nom": "Superuse Studios",
|
||||||
|
"pays": "NL",
|
||||||
|
"ville": "Rotterdam",
|
||||||
|
"type": "agence",
|
||||||
|
"url": "https://superuse-studios.com",
|
||||||
|
"lat": 51.9225,
|
||||||
|
"lng": 4.4792,
|
||||||
|
"description": "Pionnier néerlandais du réemploi industriel. Harvest Map (cartographie ressources). Projets emblématiques (Villa Welpeloo). Antériorité sur le sujet en Europe.",
|
||||||
|
"criteres": [1, 2, 5, 8],
|
||||||
|
"score": 4,
|
||||||
|
"tags": ["réemploi", "harvest map", "industriel", "NL"],
|
||||||
|
"source": "connaissance acteur",
|
||||||
|
"passe": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 19,
|
||||||
|
"nom": "Atelier 56S",
|
||||||
|
"pays": "FR",
|
||||||
|
"ville": "Vannes",
|
||||||
|
"type": "agence",
|
||||||
|
"url": "https://56s.fr",
|
||||||
|
"lat": 47.6559,
|
||||||
|
"lng": -2.7603,
|
||||||
|
"description": "Agence morbihannaise, bois et paille, équipements publics ruraux. Représentante du courant biosourcé Bretagne. Pratique de mise en œuvre soignée, ancrage local.",
|
||||||
|
"criteres": [1, 2, 3],
|
||||||
|
"score": 3,
|
||||||
|
"tags": ["bois", "paille", "Bretagne", "rural"],
|
||||||
|
"source": "connaissance acteur",
|
||||||
|
"passe": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 20,
|
||||||
|
"nom": "Quatorze",
|
||||||
|
"pays": "FR",
|
||||||
|
"ville": "Paris",
|
||||||
|
"type": "asso",
|
||||||
|
"url": "https://quatorze.cc",
|
||||||
|
"lat": 48.8566,
|
||||||
|
"lng": 2.3522,
|
||||||
|
"description": "Association d'architecture. Projets autour des migrations, du logement d'urgence, des fab labs (Fab City). Posture militante, modèle associatif assumé.",
|
||||||
|
"criteres": [3, 4, 5, 6],
|
||||||
|
"score": 4,
|
||||||
|
"tags": ["asso", "migrations", "fab city", "militant"],
|
||||||
|
"source": "connaissance acteur",
|
||||||
|
"passe": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 21,
|
||||||
|
"nom": "Yes We Camp",
|
||||||
|
"pays": "FR",
|
||||||
|
"ville": "Marseille",
|
||||||
|
"type": "collectif",
|
||||||
|
"url": "https://yeswecamp.org",
|
||||||
|
"lat": 43.2965,
|
||||||
|
"lng": 5.3698,
|
||||||
|
"description": "Tiers-lieux et occupation transitoire. Les Grands Voisins, Coco Velten. Modèle hybride architecture/programmation/animation. Ancrage friches.",
|
||||||
|
"criteres": [3, 4, 5, 6],
|
||||||
|
"score": 4,
|
||||||
|
"tags": ["tiers-lieux", "friches", "transitoire", "programmation"],
|
||||||
|
"source": "connaissance acteur",
|
||||||
|
"passe": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 22,
|
||||||
|
"nom": "Bellastock",
|
||||||
|
"pays": "FR",
|
||||||
|
"ville": "Île-Saint-Denis",
|
||||||
|
"type": "cooperative",
|
||||||
|
"url": "https://bellastock.com",
|
||||||
|
"lat": 48.9408,
|
||||||
|
"lng": 2.3472,
|
||||||
|
"description": "Coopérative spécialisée réemploi. Festival Bellastock annuel (chantier expérimental étudiants). Recherche + maîtrise d'œuvre. Une des entrées historiques du réemploi en France.",
|
||||||
|
"criteres": [1, 2, 3, 8],
|
||||||
|
"score": 4,
|
||||||
|
"tags": ["réemploi", "festival", "étudiants", "coopérative"],
|
||||||
|
"source": "connaissance acteur",
|
||||||
|
"passe": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 23,
|
||||||
|
"nom": "Mario Cucinella Architects (TECLA)",
|
||||||
|
"pays": "IT",
|
||||||
|
"ville": "Bologne",
|
||||||
|
"type": "agence",
|
||||||
|
"url": "https://mcarchitects.it",
|
||||||
|
"lat": 44.4949,
|
||||||
|
"lng": 11.3426,
|
||||||
|
"description": "MCA. Projet TECLA : maison imprimée 3D en terre locale (avec WASP). Posture bioclimatique forte, démonstrateurs. Référence italienne sur l'expérimentation matériaux+vivant.",
|
||||||
|
"criteres": [1, 3, 7, 8],
|
||||||
|
"score": 4,
|
||||||
|
"tags": ["terre", "impression 3D", "bioclimatique", "IT"],
|
||||||
|
"source": "connaissance acteur",
|
||||||
|
"passe": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 24,
|
||||||
|
"nom": "Ateliermob",
|
||||||
|
"pays": "PT",
|
||||||
|
"ville": "Lisbonne",
|
||||||
|
"type": "collectif",
|
||||||
|
"url": "https://ateliermob.com",
|
||||||
|
"lat": 38.7169,
|
||||||
|
"lng": -9.1399,
|
||||||
|
"description": "Collectif engagé 'Trabalhar com os 99%'. Autoconstruction, projets sociaux, Biennale Venise 2012. Architecture participative pour les quartiers informels et communautés défavorisées.",
|
||||||
|
"criteres": [3, 4, 5, 6, 8],
|
||||||
|
"score": 5,
|
||||||
|
"tags": ["autoconstruction", "social", "Biennale Venise 2012"],
|
||||||
|
"source": "seed passe 2 axe 1",
|
||||||
|
"passe": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 25,
|
||||||
|
"nom": "Artéria",
|
||||||
|
"pays": "PT",
|
||||||
|
"ville": "Lisbonne",
|
||||||
|
"type": "agence",
|
||||||
|
"url": "https://arteria.pt",
|
||||||
|
"lat": 38.7169,
|
||||||
|
"lng": -9.1399,
|
||||||
|
"description": "Atelier de réhabilitation urbaine. Edifício Manifesto, Mouraria. Réhabilitation sensible du patrimoine populaire lisbonnais, réemploi de matériaux, pratique frugale.",
|
||||||
|
"criteres": [1, 3, 4, 5],
|
||||||
|
"score": 4,
|
||||||
|
"tags": ["réhabilitation", "Edifício Manifesto", "Mouraria"],
|
||||||
|
"source": "seed passe 2 axe 1",
|
||||||
|
"passe": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 26,
|
||||||
|
"nom": "oitoo",
|
||||||
|
"pays": "PT",
|
||||||
|
"ville": "Porto",
|
||||||
|
"type": "agence",
|
||||||
|
"url": "https://oitoo.pt",
|
||||||
|
"lat": 41.1579,
|
||||||
|
"lng": -8.6291,
|
||||||
|
"description": "Réemploi, réactivation multi-sites. Pratique entre Porto et Lisbonne. Architecture de la transformation et du réemploi comme posture systématique.",
|
||||||
|
"criteres": [1, 3, 5, 8],
|
||||||
|
"score": 4,
|
||||||
|
"tags": ["réemploi", "réactivation", "multi-sites"],
|
||||||
|
"source": "seed passe 2 axe 1",
|
||||||
|
"passe": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 27,
|
||||||
|
"nom": "Tegnestuen LOKAL",
|
||||||
|
"pays": "DK",
|
||||||
|
"ville": "Copenhague",
|
||||||
|
"type": "agence",
|
||||||
|
"url": "https://tegnestuenlokal.dk",
|
||||||
|
"lat": 55.6761,
|
||||||
|
"lng": 12.5683,
|
||||||
|
"description": "Adaptive reuse, matériaux biogéniques, Manifest planétär. Posture frugale radicale, bois local, engagement écologique de fond dans toute la pratique.",
|
||||||
|
"criteres": [1, 2, 3, 7, 8],
|
||||||
|
"score": 5,
|
||||||
|
"tags": ["adaptive reuse", "biogenic", "Manifest planetær"],
|
||||||
|
"source": "seed passe 2 axe 1",
|
||||||
|
"passe": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 28,
|
||||||
|
"nom": "Vandkunsten Architects",
|
||||||
|
"pays": "DK",
|
||||||
|
"ville": "Copenhague",
|
||||||
|
"type": "agence",
|
||||||
|
"url": "https://vandkunsten.com",
|
||||||
|
"lat": 55.6761,
|
||||||
|
"lng": 12.5683,
|
||||||
|
"description": "Agence danoise historique, beauté + sobriété, posture engagée sur la longévité du bâti. Publications, transmission de savoir-faire architectural durable.",
|
||||||
|
"criteres": [1, 3, 5, 8],
|
||||||
|
"score": 4,
|
||||||
|
"tags": ["beauté+sobriété", "posture", "longévité"],
|
||||||
|
"source": "seed passe 2 axe 1",
|
||||||
|
"passe": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 29,
|
||||||
|
"nom": "White Arkitekter",
|
||||||
|
"pays": "SE",
|
||||||
|
"ville": "Stockholm",
|
||||||
|
"type": "agence",
|
||||||
|
"url": "https://whitearkitekter.com",
|
||||||
|
"lat": 59.3293,
|
||||||
|
"lng": 18.0686,
|
||||||
|
"description": "Grande agence suédoise engagée sur les modes de vie durables. Matériaux biosourcés à grande échelle, publications, transmission du savoir-faire nordique.",
|
||||||
|
"criteres": [1, 3, 5, 8],
|
||||||
|
"score": 4,
|
||||||
|
"tags": ["sustainable ways of life", "scale"],
|
||||||
|
"source": "seed passe 2 axe 1",
|
||||||
|
"passe": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 30,
|
||||||
|
"nom": "ZRS Architekten Ingenieure",
|
||||||
|
"pays": "DE",
|
||||||
|
"ville": "Berlin",
|
||||||
|
"type": "agence",
|
||||||
|
"url": "https://zrs.berlin",
|
||||||
|
"lat": 52.5200,
|
||||||
|
"lng": 13.4050,
|
||||||
|
"description": "Terre, bambou, construction biosourcée en Allemagne et en Afrique subsaharienne (NBL). Recherche appliquée couplée à la pratique. Référence germanophone sur les matériaux locaux et vernaculaires.",
|
||||||
|
"criteres": [1, 2, 3, 5, 8],
|
||||||
|
"score": 5,
|
||||||
|
"tags": ["terre", "bambou", "NBL", "recherche"],
|
||||||
|
"source": "seed passe 2 axe 1",
|
||||||
|
"passe": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 31,
|
||||||
|
"nom": "Natural Building Lab (TU Berlin)",
|
||||||
|
"pays": "DE",
|
||||||
|
"ville": "Berlin",
|
||||||
|
"type": "recherche",
|
||||||
|
"url": "https://nbl.berlin",
|
||||||
|
"lat": 52.5200,
|
||||||
|
"lng": 13.4050,
|
||||||
|
"description": "Laboratoire universitaire low-tech à la TU Berlin. Transmission, expérimentation constructive avec matériaux naturels, lien recherche-pratique exemplaire.",
|
||||||
|
"criteres": [1, 3, 5, 8],
|
||||||
|
"score": 4,
|
||||||
|
"tags": ["low-tech", "université", "transmission"],
|
||||||
|
"source": "seed passe 2 axe 1",
|
||||||
|
"passe": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 32,
|
||||||
|
"nom": "DGJ Architektur",
|
||||||
|
"pays": "DE",
|
||||||
|
"ville": "Frankfurt",
|
||||||
|
"type": "agence",
|
||||||
|
"url": "https://dgj.eu",
|
||||||
|
"lat": 50.1109,
|
||||||
|
"lng": 8.6821,
|
||||||
|
"description": "Agence engagée sur le durable et le biosourcé en Allemagne. Pratique soignée, matériaux biosourcés intégrés aux projets résidentiels et publics.",
|
||||||
|
"criteres": [1, 3, 8],
|
||||||
|
"score": 3,
|
||||||
|
"tags": ["sustainable", "biosourcés"],
|
||||||
|
"source": "seed passe 2 axe 1",
|
||||||
|
"passe": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 33,
|
||||||
|
"nom": "Baubüro in situ + Zirkular",
|
||||||
|
"pays": "CH",
|
||||||
|
"ville": "Bâle",
|
||||||
|
"type": "agence",
|
||||||
|
"url": "https://insitu.ch",
|
||||||
|
"lat": 47.5596,
|
||||||
|
"lng": 7.5886,
|
||||||
|
"description": "Réemploi structurel de référence en Suisse. K118, Holcim Gold Award. Pratique circulaire systématique, publications, posture publique affirmée.",
|
||||||
|
"criteres": [1, 2, 5, 6, 8],
|
||||||
|
"score": 5,
|
||||||
|
"tags": ["K118", "réemploi structurel", "Holcim Gold"],
|
||||||
|
"source": "seed passe 2 axe 1",
|
||||||
|
"passe": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 34,
|
||||||
|
"nom": "einszueins architektur",
|
||||||
|
"pays": "AT",
|
||||||
|
"ville": "Vienne",
|
||||||
|
"type": "agence",
|
||||||
|
"url": "https://einszueins.at",
|
||||||
|
"lat": 48.2082,
|
||||||
|
"lng": 16.3738,
|
||||||
|
"description": "Wohnprojekt Wien, baugruppe, World Habitat Award. Habitat coopératif et participatif à Vienne. Modèle alternatif de co-construction avec futurs habitants.",
|
||||||
|
"criteres": [3, 4, 5, 6, 8],
|
||||||
|
"score": 5,
|
||||||
|
"tags": ["Wohnprojekt Wien", "baugruppe", "World Habitat Award"],
|
||||||
|
"source": "seed passe 2 axe 1",
|
||||||
|
"passe": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 35,
|
||||||
|
"nom": "BudCud",
|
||||||
|
"pays": "PL",
|
||||||
|
"ville": "Cracovie",
|
||||||
|
"type": "agence",
|
||||||
|
"url": "https://budcud.org",
|
||||||
|
"lat": 50.0647,
|
||||||
|
"lng": 19.9450,
|
||||||
|
"description": "Réemploi, mobilier urbain, pratique engagée à Cracovie. Projets d'espace public avec matériaux récupérés, participation des usagers.",
|
||||||
|
"criteres": [1, 3, 4, 8],
|
||||||
|
"score": 4,
|
||||||
|
"tags": ["réemploi", "mobilier", "urbain"],
|
||||||
|
"source": "seed passe 2 axe 1",
|
||||||
|
"passe": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 36,
|
||||||
|
"nom": "Centrala",
|
||||||
|
"pays": "PL",
|
||||||
|
"ville": "Varsovie",
|
||||||
|
"type": "agence",
|
||||||
|
"url": "https://centrala.net.pl",
|
||||||
|
"lat": 52.2297,
|
||||||
|
"lng": 21.0122,
|
||||||
|
"description": "Recherche spatiale, posture critique. Agence-recherche varsovienne engagée sur les transformations urbaines post-industrielles.",
|
||||||
|
"criteres": [3, 5, 8],
|
||||||
|
"score": 3,
|
||||||
|
"tags": ["recherche spatiale", "posture"],
|
||||||
|
"source": "seed passe 2 axe 1",
|
||||||
|
"passe": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 37,
|
||||||
|
"nom": "RiceHouse",
|
||||||
|
"pays": "IT",
|
||||||
|
"ville": "Biella",
|
||||||
|
"type": "agence",
|
||||||
|
"url": "https://ricehouse.it",
|
||||||
|
"lat": 45.5629,
|
||||||
|
"lng": 8.0565,
|
||||||
|
"description": "Paille de riz comme matériau de construction. Filière biosourcée ultra-locale (Plaine du Pô). Recherche + démonstrations, lien producteurs-constructeurs.",
|
||||||
|
"criteres": [1, 2, 3, 7, 8],
|
||||||
|
"score": 5,
|
||||||
|
"tags": ["paille de riz", "biosourcé", "filière courte"],
|
||||||
|
"source": "seed passe 2 axe 1",
|
||||||
|
"passe": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 38,
|
||||||
|
"nom": "TAMassociati",
|
||||||
|
"pays": "IT",
|
||||||
|
"ville": "Venise",
|
||||||
|
"type": "agence",
|
||||||
|
"url": "https://tamassociati.org",
|
||||||
|
"lat": 45.4408,
|
||||||
|
"lng": 12.3155,
|
||||||
|
"description": "'Taking care' comme posture. Architecture humanitaire et sociale en Italie et à l'international. Projets de santé et d'éducation dans des contextes fragiles.",
|
||||||
|
"criteres": [3, 4, 5, 8],
|
||||||
|
"score": 4,
|
||||||
|
"tags": ["taking care", "humanitaire", "social"],
|
||||||
|
"source": "seed passe 2 axe 1",
|
||||||
|
"passe": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 39,
|
||||||
|
"nom": "Diverserighestudio",
|
||||||
|
"pays": "IT",
|
||||||
|
"ville": "Bologne",
|
||||||
|
"type": "agence",
|
||||||
|
"url": "https://diverserighestudio.it",
|
||||||
|
"lat": 44.4949,
|
||||||
|
"lng": 11.3426,
|
||||||
|
"description": "Cohousing Porto15, recherche, participatif. Studio bolonais engagé sur l'habitat participatif et la recherche architecturale, modèle coopératif.",
|
||||||
|
"criteres": [3, 4, 5, 6, 8],
|
||||||
|
"score": 5,
|
||||||
|
"tags": ["cohousing Porto15", "recherche", "participatif"],
|
||||||
|
"source": "seed passe 2 axe 1",
|
||||||
|
"passe": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 40,
|
||||||
|
"nom": "Material Cultures",
|
||||||
|
"pays": "UK",
|
||||||
|
"ville": "Londres",
|
||||||
|
"type": "asso",
|
||||||
|
"url": "https://materialcultures.org",
|
||||||
|
"lat": 51.5074,
|
||||||
|
"lng": -0.1278,
|
||||||
|
"description": "Biosourcés, chanvre (hempcrete), Harvest House. Association de recherche sur les cultures matérielles biosourcées et leur transformation en techniques constructives.",
|
||||||
|
"criteres": [1, 2, 5, 8],
|
||||||
|
"score": 4,
|
||||||
|
"tags": ["biosourcés", "hempcrete", "Harvest House"],
|
||||||
|
"source": "seed passe 2 axe 1",
|
||||||
|
"passe": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 41,
|
||||||
|
"nom": "6a architects",
|
||||||
|
"pays": "UK",
|
||||||
|
"ville": "Londres",
|
||||||
|
"type": "agence",
|
||||||
|
"url": "https://6a.co.uk",
|
||||||
|
"lat": 51.5074,
|
||||||
|
"lng": -0.1278,
|
||||||
|
"description": "Strates, retrofit, transmission. Agence londonienne engagée sur la transformation du bâti existant, lecture des strates temporelles et sobriété des interventions.",
|
||||||
|
"criteres": [1, 3, 5, 8],
|
||||||
|
"score": 4,
|
||||||
|
"tags": ["strates", "retrofit", "transmission"],
|
||||||
|
"source": "seed passe 2 axe 1",
|
||||||
|
"passe": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 42,
|
||||||
|
"nom": "We Made That",
|
||||||
|
"pays": "UK",
|
||||||
|
"ville": "Londres",
|
||||||
|
"type": "agence",
|
||||||
|
"url": "https://wemadethat.co.uk",
|
||||||
|
"lat": 51.5074,
|
||||||
|
"lng": -0.1278,
|
||||||
|
"description": "Public realm, co-conception, civic. Agence engagée sur les espaces publics et l'aménagement participatif en contexte urbain dense.",
|
||||||
|
"criteres": [4, 5, 6, 8],
|
||||||
|
"score": 4,
|
||||||
|
"tags": ["public realm", "co-conception", "civic"],
|
||||||
|
"source": "seed passe 2 axe 1",
|
||||||
|
"passe": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 43,
|
||||||
|
"nom": "Public Practice",
|
||||||
|
"pays": "UK",
|
||||||
|
"ville": "Londres",
|
||||||
|
"type": "asso",
|
||||||
|
"url": "https://publicpractice.org.uk",
|
||||||
|
"lat": 51.5074,
|
||||||
|
"lng": -0.1278,
|
||||||
|
"description": "Architectes placés en mairies pour œuvrer dans l'intérêt public. Modèle innovant d'insertion professionnelle au service de la commande publique locale.",
|
||||||
|
"criteres": [4, 5, 6, 8],
|
||||||
|
"score": 4,
|
||||||
|
"tags": ["architectes en mairies", "intérêt public"],
|
||||||
|
"source": "seed passe 2 axe 1",
|
||||||
|
"passe": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 44,
|
||||||
|
"nom": "AgwA",
|
||||||
|
"pays": "BE",
|
||||||
|
"ville": "Bruxelles",
|
||||||
|
"type": "agence",
|
||||||
|
"url": "https://agwa.be",
|
||||||
|
"lat": 50.8503,
|
||||||
|
"lng": 4.3517,
|
||||||
|
"description": "Transformation, EUmies 2026, Charleroi. Agence bruxelloise spécialisée en transformation du bâti existant, posture critique et réemploi intégré.",
|
||||||
|
"criteres": [1, 5, 8],
|
||||||
|
"score": 3,
|
||||||
|
"tags": ["transformation", "EUmies 2026", "Charleroi"],
|
||||||
|
"source": "seed passe 2 axe 1",
|
||||||
|
"passe": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 45,
|
||||||
|
"nom": "Carton123 architecten",
|
||||||
|
"pays": "BE",
|
||||||
|
"ville": "Bruxelles",
|
||||||
|
"type": "agence",
|
||||||
|
"url": "https://carton123.be",
|
||||||
|
"lat": 50.8503,
|
||||||
|
"lng": 4.3517,
|
||||||
|
"description": "Réemploi, école, collaboration. Agence bruxelloise engagée sur les projets scolaires avec une forte dimension réemploi et collaboration avec les usagers.",
|
||||||
|
"criteres": [1, 3, 5, 8],
|
||||||
|
"score": 4,
|
||||||
|
"tags": ["réemploi", "school", "collaboration"],
|
||||||
|
"source": "seed passe 2 axe 1",
|
||||||
|
"passe": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 46,
|
||||||
|
"nom": "KEBATI",
|
||||||
|
"pays": "MQ",
|
||||||
|
"ville": "Fort-de-France",
|
||||||
|
"type": "asso",
|
||||||
|
"url": "https://www.kebati.com",
|
||||||
|
"lat": 14.6161,
|
||||||
|
"lng": -61.0588,
|
||||||
|
"description": "Association citoyenne martiniquaise dédiée au bâtiment durable en milieu tropical humide. Centre de ressources reconnu nationalement (22 centres ADEME). 236 visites de chantiers, groupes de travail biosourcés, newsletter 800 abonnés.",
|
||||||
|
"criteres": [1, 2, 5, 8],
|
||||||
|
"score": 4,
|
||||||
|
"tags": ["biosourcés", "tropical", "transmission", "Martinique", "centre de ressources"],
|
||||||
|
"source": "P1-RECAP scrape kebati.com",
|
||||||
|
"passe": 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 47,
|
||||||
|
"nom": "AQUAA",
|
||||||
|
"pays": "GF",
|
||||||
|
"ville": "Cayenne",
|
||||||
|
"type": "asso",
|
||||||
|
"url": "https://www.aquaa.fr",
|
||||||
|
"lat": 4.9333,
|
||||||
|
"lng": -52.3333,
|
||||||
|
"description": "Association pour la qualité de la construction en Amazonie et aux Antilles. Guides techniques d'architecture bioclimatique guyanaise, éco-matériaux locaux, urbanisme équatorial. Membre fondateur du RBD Intertropical.",
|
||||||
|
"criteres": [2, 3, 5, 8],
|
||||||
|
"score": 4,
|
||||||
|
"tags": ["bioclimatique", "amazonien", "éco-matériaux", "Guyane"],
|
||||||
|
"source": "P1-RECAP — scrape échoué (ECONNREFUSED) — à compléter manuellement",
|
||||||
|
"passe": 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 48,
|
||||||
|
"nom": "Karibati",
|
||||||
|
"pays": "FR",
|
||||||
|
"ville": "Paris",
|
||||||
|
"type": "reseau",
|
||||||
|
"url": "http://www.karibati.fr",
|
||||||
|
"lat": 48.8566,
|
||||||
|
"lng": 2.3522,
|
||||||
|
"description": "Expert national du bâtiment biosourcé et géosourcé avec 15 ans d'expérience. Développement et structuration de filières biosourcées, accompagnement fabricants, formations, outils méthodologiques (aKacia, Label Produit Biosourcé).",
|
||||||
|
"criteres": [1, 2, 5, 8],
|
||||||
|
"score": 4,
|
||||||
|
"tags": ["biosourcés", "géosourcés", "filières", "formation", "national"],
|
||||||
|
"source": "P1-RECAP scrape karibati.fr",
|
||||||
|
"passe": 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 49,
|
||||||
|
"nom": "Réseau Bâtiment Durable Intertropical (RBD)",
|
||||||
|
"pays": "GP",
|
||||||
|
"ville": "Pointe-à-Pitre",
|
||||||
|
"type": "reseau",
|
||||||
|
"url": "https://www.synergile.fr/departements/rbd/",
|
||||||
|
"lat": 16.2410,
|
||||||
|
"lng": -61.5337,
|
||||||
|
"description": "Réseau intertropical de 4 structures (AQUAA Guyane, KEBATI Martinique, Envirobat Réunion, RBD Guadeloupe). Réponse concertée aux enjeux du bâtiment durable tropical. Pôle d'innovation Synergîles, financement ADEME.",
|
||||||
|
"criteres": [1, 2, 5, 8],
|
||||||
|
"score": 4,
|
||||||
|
"tags": ["réseau", "intertropical", "DOM-TOM", "ADEME"],
|
||||||
|
"source": "P1-RECAP scrape synergile.fr/rbd",
|
||||||
|
"passe": 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 50,
|
||||||
|
"nom": "Caribois",
|
||||||
|
"pays": "GP",
|
||||||
|
"ville": "Baie-Mahault",
|
||||||
|
"type": "agence",
|
||||||
|
"url": "https://www.caribois.com",
|
||||||
|
"lat": 16.2410,
|
||||||
|
"lng": -61.5337,
|
||||||
|
"description": "Constructeur de maisons individuelles en bois, béton ou mixte depuis 20 ans en Guadeloupe. Fabrication 100% locale, matériaux biosourcés (bois sélectionné), architecture créole. Accompagnement complet du financement à la livraison.",
|
||||||
|
"criteres": [1, 2],
|
||||||
|
"score": 2,
|
||||||
|
"tags": ["bois", "créole", "local", "Guadeloupe"],
|
||||||
|
"source": "P1-RECAP scrape caribois.com — constructeur commercial (pas collectif praticien au sens strict)",
|
||||||
|
"passe": 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 51,
|
||||||
|
"nom": "Vegetal(e)",
|
||||||
|
"pays": "GP",
|
||||||
|
"ville": "Pointe-à-Pitre",
|
||||||
|
"type": "plateforme",
|
||||||
|
"url": "http://www.vegetal-e.com/fr/guadeloupe_384.html",
|
||||||
|
"lat": 16.2410,
|
||||||
|
"lng": -61.5337,
|
||||||
|
"description": "Portail d'information et base de données sur les éco-matériaux et solutions constructives biosourcés en Guadeloupe. Agrégateur de ressources régionales (chanvre, cellulose, bois, bambou, paille). Newsletter hebdomadaire sur l'innovation construction.",
|
||||||
|
"criteres": [1, 5, 8],
|
||||||
|
"score": 3,
|
||||||
|
"tags": ["biosourcés", "portail", "Guadeloupe", "éco-matériaux"],
|
||||||
|
"source": "P1-RECAP scrape vegetal-e.com — portail info, pas une agence praticien",
|
||||||
|
"passe": 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 52,
|
||||||
|
"nom": "Envirobat Réunion",
|
||||||
|
"pays": "RE",
|
||||||
|
"ville": "Saint-Denis",
|
||||||
|
"type": "reseau",
|
||||||
|
"url": "https://www.envirobat.re",
|
||||||
|
"lat": -20.8789,
|
||||||
|
"lng": 55.4481,
|
||||||
|
"description": "Centre de ressources bâtiment durable de La Réunion. Membre fondateur du RBD Intertropical. Équivalent insulaire de KEBATI pour l'océan Indien, transmission et formation des professionnels du bâtiment réunionnais.",
|
||||||
|
"criteres": [2, 5, 8],
|
||||||
|
"score": 3,
|
||||||
|
"tags": ["bâtiment durable", "Réunion", "centre de ressources", "intertropical"],
|
||||||
|
"source": "P1-RECAP — scrape échoué (ECONNREFUSED) — à compléter manuellement",
|
||||||
|
"passe": 3
|
||||||
|
}
|
||||||
|
]
|
||||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
Before Width: | Height: | Size: 527 B |
Binary file not shown.
|
Before Width: | Height: | Size: 1.1 KiB |
@@ -1,21 +0,0 @@
|
|||||||
<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>
|
|
||||||
|
Before Width: | Height: | Size: 892 B |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user