Compare commits
38
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ebfc744574 | ||
|
|
8f94a1acda | ||
|
|
288a9c9126 | ||
|
|
a1edcf3093 | ||
|
|
043e2e2348 | ||
|
|
695cc8bbc2 | ||
|
|
09d1dc9d5f | ||
|
|
58937a04a3 | ||
|
|
38818f77ae | ||
|
|
d9f934a8b2 | ||
|
|
baa602e5f8 | ||
|
|
6087b8dd31 | ||
|
|
13af3fa1c5 | ||
|
|
1838f4b02d | ||
|
|
5b504b3e04 | ||
|
|
b2b8c13fee | ||
|
|
4b1340f160 | ||
|
|
0903b8989b | ||
|
|
b5aca80bbc | ||
|
|
946ed53aba | ||
|
|
d06c09a476 | ||
|
|
4291fe7529 | ||
|
|
e5d57e0053 | ||
|
|
3cf9f721a7 | ||
|
|
04b023518a | ||
|
|
e61114d6ec | ||
|
|
c860dabdc0 | ||
|
|
f3ab28bd69 | ||
|
|
cd8fe9e258 | ||
|
|
ea7c8cc91e | ||
|
|
538c490e76 | ||
|
|
d584d04e3d | ||
|
|
bd95c0f00d | ||
|
|
db8f614928 | ||
|
|
59cb81a055 | ||
|
|
91e3466ec6 | ||
|
|
cb75889231 | ||
|
|
422f45116f |
@@ -7,3 +7,4 @@ dist
|
||||
*.log
|
||||
|
||||
*.tmp.*
|
||||
.firecrawl/
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
# Design system nav-carte (AEP)
|
||||
|
||||
Ce fichier existe pour que B6 à B9 n'aient pas à relire les récaps de B3 (`1 PROJETS/DEV/AEP/Cascade-Refonte/recaps/B3-*.md` dans le vault Dropbox) pour comprendre ce qui a été posé. Si tu es sur B6+ et que tu touches au visuel de nav-carte, lis ceci d'abord.
|
||||
|
||||
## D'où viennent les tokens
|
||||
|
||||
Les couleurs, espacements, rayons, ombres, etc. ne sont **jamais** des valeurs en dur dans les composants nav-carte. Ils viennent de deux fichiers, chargés dans cet ordre dans `nuxt.config.ts` :
|
||||
|
||||
1. `@transformer/ui/tokens.css` — package npm partagé (`git+https://git.trans-former.fr/jules/design-system.git`), le schéma canonique de tokens par **rôle** (`--bg`, `--text`, `--primary`, `--focus-ring`, `--space-*`, `--radius-*`, `--fs-*`, etc.). Jamais cloné en local, jamais modifié depuis ce repo.
|
||||
2. `@transformer/ui/theme/aep.css` — la palette AEP spécifique (« Sobre institutionnel », validée par Jules), qui surcharge les rôles du point 1 avec les vraies couleurs : bleu nuit `#1a2238`, accent safran `#f5b342`, fond crème `#f8f6f1`. C'est ce fichier qui rend `--primary`, `--accent`, `--bg` etc. concrets pour AEP.
|
||||
|
||||
`assets/css/main.css` définit ensuite des variables `--nav-*` (héritées de l'ancienne palette V1, avant l'arrivée du DS). Elles sont **alias** des rôles ci-dessus : `--nav-bg: var(--bg)`, `--nav-text: var(--text)`, etc. Elles existent uniquement parce que tout le code existant (avant B3) les consomme — ne pas les supprimer sans grep préalable, ne pas en créer de nouvelles : pour du code neuf, consommer directement les tokens `--bg`/`--text`/`--primary`/... du DS.
|
||||
|
||||
**Trois variables restent volontairement non-aliasées**, propres à nav-carte :
|
||||
|
||||
- `--nav-primary-raw` — triplet RGB (`26, 34, 56`) utilisé dans des `rgba(var(--nav-primary-raw), x)`. Le DS n'expose pas de triplet RGB séparé, seulement des couleurs finales.
|
||||
- `--nav-primary-solid` — bleu nuit plein (`#1a2238` clair, `#c8d2f0` sombre). Différent de `--primary` du DS, qui est le bleu nuit à 60 % d'opacité (utilisé pour les surfaces translucides). `--nav-primary-solid` sert au texte et aux éléments qui doivent rester lisibles en plein (logo, soulignement d'onglet actif).
|
||||
- `--nav-text-on-primary` — texte de contraste, gardé en valeur littérale (`#f8f6f1` clair / `#111520` sombre) plutôt qu'aliasé sur `--on-primary` du DS. **Raison** : cette variable sert de texte de contraste sur DEUX fonds différents dans le code existant — `--nav-primary` (translucide) ET `--nav-primary-solid` (opaque) — qui ont une légèreté **inversée** en mode sombre (le translucide rend sombre une fois mélangé au fond, le solid `#c8d2f0` est un lavande clair). Le rôle `--on-primary` du DS ne colle qu'au premier cas ; l'aliaser cassait le contraste à une dizaine d'endroits (logo « AEP », boutons, bandeaux) en sombre. Si tu ajoutes un nouvel usage de cette variable, vérifie d'abord sur QUEL fond (translucide ou solid) le texte doit être lisible.
|
||||
|
||||
Dark mode : classe `.dark` posée sur `<html>` (`document.documentElement.classList.add('dark')`, persisté dans `localStorage.aep_theme`). `theme/aep.css` définit ses valeurs sombres sous le sélecteur `:root.dark, :root[data-theme="dark"]` — les deux formes sont acceptées, mais nav-carte n'utilise que `.dark`. Pas de bascule automatique par `prefers-color-scheme` (décision explicite : le site n'en a jamais eu, ça changerait ce que voient les visiteurs par défaut).
|
||||
|
||||
## Grammaire des chips par dimension
|
||||
|
||||
Trois dimensions, trois traitements visuels — posés en B3-M1, pensés pour rester stables jusqu'à B4+ (nouvelles dimensions à ajouter, pas à réinventer) :
|
||||
|
||||
| Dimension | Variant | Rendu inactif | Rendu actif |
|
||||
|---|---|---|---|
|
||||
| Échelle (national/régional/local) | `echelle` | outline, fond transparent | fond `--primary` plein, texte `--on-primary` |
|
||||
| Fonction / hashtag | `fonction` (défaut) | fond `--bg-alt` | fond `--primary` plein, texte `--on-primary` |
|
||||
| Posture (préparée pour B4, pas encore consommée) | `posture` | outline safran (`--accent`) | fond `--accent` plein, texte `--on-accent` **sombre** — jamais blanc sur safran (voir règle de contraste plus bas) |
|
||||
|
||||
Composants : `components/Chip.vue` (une chip, `<button>` ou `<span>` selon `as`) et `components/ChipGroup.vue` (conteneur `role="group"`, `flex-wrap`, gap). Toujours les deux ensemble — jamais une chip nue hors groupe (accessibilité : le groupe porte l'`aria-label` qui dit ce qu'on filtre).
|
||||
|
||||
```vue
|
||||
<ChipGroup aria-label="Filtrer par fonction">
|
||||
<Chip
|
||||
v-for="fn in fonctions"
|
||||
:key="fn"
|
||||
:label="fn"
|
||||
variant="fonction"
|
||||
:active="selected.includes(fn)"
|
||||
@toggle="toggleFonction(fn)"
|
||||
/>
|
||||
</ChipGroup>
|
||||
```
|
||||
|
||||
Pour une chip non cliquable (affichage seul, ex. liste de résultats) : `as="span"` — pas de `type="button"`, pas d'`aria-pressed`, pas de handler `@toggle` nécessaire.
|
||||
|
||||
## FicheMiniCard + useFicheAdapter — brancher une nouvelle source
|
||||
|
||||
`composables/useFicheAdapter.ts` expose `toMini(source): MiniFiche`, où `MiniFiche = { id, nom, resume, chips, echelle?, geoloc, kind }`. Aujourd'hui il distingue `Org` (NocoDB, page `/`) et `StructureV2` (JSON statique `reseaux-bifurcation.json`, page `/agences`) via `'famille_principale' in source` (seul champ propre à `StructureV2`).
|
||||
|
||||
**Pour brancher une 3e source** (B6+, ex. un nouveau type de fiche) :
|
||||
1. Ajoute son type à l'union `Org | StructureV2 | TonNouveauType` dans la signature de `toMini`.
|
||||
2. Ajoute une garde de type (comme `isStructure`) basée sur un champ qui n'existe QUE dans ton nouveau type — ne réutilise pas `famille_principale` ou un champ ambigu.
|
||||
3. Choisis l'ordre de repli du résumé : `buildResume([champA, champB, ...])` prend le premier champ non vide, en garde la première phrase, tronque à 110 caractères sur un mot. Vérifie quels champs de ton type contiennent du texte descriptif avant de les ordonner.
|
||||
4. `chips` = 3 étiquettes maximum (fonctions, hashtags, ou l'équivalent pour ton type). `geoloc` = `latitude != null && longitude != null` si ton type a des coordonnées, sinon `true` (pas de fiche à masquer côté carte).
|
||||
|
||||
`components/FicheMiniCard.vue` consomme uniquement `MiniFiche` — il n'a jamais besoin de connaître `Org` ou `StructureV2`. C'est ce découplage qui permet d'ajouter une source sans toucher au composant d'affichage.
|
||||
|
||||
## FichesPanel — le panneau « toutes les fiches »
|
||||
|
||||
`components/FichesPanel.vue` : props `fiches: MiniFiche[]` (déjà filtrée par la page appelante — le panneau ne refiltre pas, ne crée pas de second champ de recherche), `hashtags: string[]` (options de filtre), `selected: string[]`, `query: string` (sert seulement à nuancer le message d'état vide), `intention: string`. Émet `update:selected` et `open(fiche)`.
|
||||
|
||||
**Le panneau ne possède pas son propre état de filtre** : ses chips pilotent la variable réactive de la page appelante via `update:selected` (ex. `fonctions` sur `/`, `selectedHashtags` sur `/agences`). C'est ce qui garantit qu'une chip cochée dans le panneau et la même chip dans la sidebar (si elle est visible) affichent toujours le même état — une seule source de vérité, jamais deux.
|
||||
|
||||
**Hashtags repliés** (B3-M4) : au-delà de 14 chips (`COLLAPSE_THRESHOLD`), seules les premières s'affichent + un chip-bouton « voir les N autres » qui déplie. Les chips déjà actives restent visibles même repliées (union entre les 14 premières et les actives hors de ce lot). État déplié non persistant — `ref` locale, reset à chaque montage. `/` (10 fonctions) ne dépasse jamais le seuil ; `/agences` (~60 hashtags) se replie par défaut.
|
||||
|
||||
**Accès** : `?vue=fiches` dans l'URL, en plus des paramètres de filtre existants. Sur `/`, `desktopMapView`/`mobileMapView` gagnent une valeur `'fiches'` à côté de `'metropole'`/`'outremer'` ; sur `/agences`, à côté de `'metropole'`/`'outremer'`/`'graphe'`. Recharger l'URL avec `?vue=fiches` doit rouvrir directement le panneau, filtres compris.
|
||||
|
||||
**Mobile** : le panneau ne se monte JAMAIS dans un `MobileSheet` (le composant sheet demi-hauteur pensé pour flotter au-dessus d'une carte) — il n'y a pas de carte derrière la vue fiches, donc pas de sheet à demi-hauteur : le panneau occupe toute la zone sous la barre d'onglets, avec son propre scroll interne. Piège vécu en B3 : monter le panneau dans un `MobileSheet` par réflexe (comme les autres onglets) laisse ~45 % de l'écran vide au-dessus, puisque la sheet démarre repliée à mi-hauteur et qu'il n'y a rien à voir en dessous.
|
||||
|
||||
## Intention d'abord
|
||||
|
||||
Principe de design validé par Jules pour B3 : un panneau (ou une carte) s'ouvre sur **une ligne d'intention** avant l'outillage (filtres, compteur, grille). La ligne d'intention dit en une phrase ce que montre l'écran et le premier geste possible (« Toutes les structures d'entraide, y compris celles sans adresse sur la carte. Cochez une fonction pour filtrer. »). Dans `FichesPanel`, l'ordre de l'en-tête est fixe : intention → compteur → chips → grille. Ne pas remonter le compteur ou les chips au-dessus de l'intention, même pour gagner de la place.
|
||||
|
||||
## Motif maillage (favicon)
|
||||
|
||||
Le favicon (`public/favicon.svg`, décliné en `favicon-32.png`, `favicon-16.png`, `apple-touch-icon.png`) dessine un petit graphe : un nœud central relié à cinq nœuds périphériques par des arêtes, sur fond bleu nuit avec des nœuds/arêtes safran. C'est un **motif à décliner**, pas une icône isolée — il préfigure les liens de graphe et les connexions entre fiches attendus en B4 (postures), B8 et B9 (structures liées, graphe de réseau déjà présent dans `FicheModalV2` via `structuresVoisines`). Si tu dessines un composant de graphe ou de connexions entre fiches dans ces batchs, reprends ce vocabulaire visuel (nœuds pleins, arêtes fines, un nœud central mis en avant) plutôt que d'en inventer un nouveau.
|
||||
|
||||
## Deux règles de contraste
|
||||
|
||||
1. **Texte sombre sur l'accent safran, jamais blanc.** `--accent`/`--on-accent` : le blanc sur `#f5b342` échoue le contrôle AA (décision DS du 20/07/2026). `--on-accent` est toujours une couleur sombre (`#1a2238` clair, `#111520` sombre). Vérifié au pixel sur la variante `posture` de `Chip.vue`.
|
||||
2. **`--nav-text-on-primary` reste une valeur littérale**, pas un alias — voir l'explication détaillée dans la section tokens ci-dessus. Si un nouveau composant a besoin de texte de contraste sur un fond bleu nuit, vérifie d'abord si ce fond est `--nav-primary` (translucide) ou `--nav-primary-solid` (opaque) avant de choisir la variable de texte : elles n'ont pas la même réponse en mode sombre.
|
||||
-105
@@ -1,6 +1,5 @@
|
||||
---
|
||||
type: journal
|
||||
note_J8: Phase 8.G LIVREE 2026-05-14 - voir PILOTE-RAG-PE.md pour details
|
||||
project: NAV V2
|
||||
created: 2026-04-14
|
||||
status: actif
|
||||
@@ -12,56 +11,6 @@ Journal technique de la V2. Décisions, anomalies, points bloquants, TODOs.
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-08 — Fix mobile + chatbot prod (cause racine résolue)
|
||||
|
||||
**Commits :** session loggée sur main (pushé sur gitea)
|
||||
**Pattern :** pilote direct, 2 batches successifs, ~3h, 11 fichiers
|
||||
|
||||
### Cause racine bug "chatbot Carte 1 == Carte 2"
|
||||
|
||||
`/api/chatbot-reseaux` était **404 en prod** (jamais déployé) — explique pourquoi 5 cycles de fix précédents (ChatbotReseaux.vue prop endpoint, useRoute fallback, useMarkdown direct, etc.) n'ont rien donné : le code source était correct depuis le début. Le rebuild + redeploy de cette session résout le bug.
|
||||
|
||||
**Verif :** `curl -s -X POST https://aep.trans-former.fr/api/chatbot-reseaux` → 200 + réponse distincte de `/api/chatbot`.
|
||||
|
||||
### Batch 1 — fixes mobile principaux
|
||||
|
||||
- Hamburger app.vue : ajout Jobs + Manifeste + Soutenir, ré-ordonnancement (Manifeste dans 2e groupe avec À propos/Soutenir/Signaler)
|
||||
- BandeauBas.vue : FAB cœur jaune mobile retiré (Soutenir migré dans hamburger via lien Liberapay direct)
|
||||
- agences.vue mobile : 3e onglet "Graphe" ajouté + masquage MobileSheet en mode graphe (canvas fullscreen)
|
||||
- a-propos.vue : section 1 "Mission" retirée (devient pop-up Carte 1) + `overflow-x: hidden` sur `.apropos-page` + retrait `white-space: nowrap` problématique sur `.badge-detail`
|
||||
- pages/manifeste.vue : nouvelle page (texte version `manifeste-page-carto-V1.md`, sans le diagramme ASCII pour V1 web)
|
||||
- components/MissionPopup.vue : nouveau composant générique (props `title`, `ctaLabel`, `storageKey`, slot pour contenu, `:slotted()` pour styles)
|
||||
- index.vue : intégration MissionPopup + bouton (i) `position:fixed` bottom-left + auto-show 1ère visite via `localStorage.aep_mission_seen`
|
||||
- trouver-du-taf.vue : toggle "Filtres [N] [chevron]" mobile-only (`@media max-width: 767px`) avec `taff-filters-collapsible` max-height transition
|
||||
- FicheModal.vue + FicheModalV2.vue : sur mobile `top: 76px` + `max-height: calc(100dvh - 92px)` au lieu de `top: 50% translate(-50%, -50%)` + `max-height: 90vh` qui mordait sur le header
|
||||
|
||||
### Batch 2 — pop-up Carte 2, logo, intro Jobs, labels graphe
|
||||
|
||||
- agences.vue : pop-up Réseaux AEP avec MissionPopup (storageKey `aep_reseaux_seen`, ctaLabel "Explorer les 120 réseaux") + bouton (i) flottant
|
||||
- app.vue logo header : badge AEP + 2 spans `logo-line-1` ("Architecture") / `logo-line-2` ("d'Écologie Politique") avec font-size responsive (0.7rem mobile → 0.85rem ≥1024)
|
||||
- trouver-du-taf.vue : `<details class="taff-pedago" open>` avec 3 blocs (deux onglets, trois étiquettes, cinq axes) + onglet "Plateformes B2C" → "Pour archi indépendants"
|
||||
- GraphView.vue : `d3NodeSelection.filter(type==='structure').append('text')` avec class `graph-struct-label`, `dy: -(d.r + 5)`, font-size 9.5px, halo via `paint-order: stroke; stroke: var(--nav-bg)` (style global non-scoped pour piercer D3)
|
||||
|
||||
### Bug d'opération à retenir
|
||||
|
||||
Lors du 1er déploiement batch 2, `bash deploy.sh` semblait OK (HTTP 200) mais le HTML en prod ne contenait pas les modifs. **Cause** : Dropbox sync a effacé `.output/` entre `npm run build` et le tar SCP — le tar a uploadé un `.output` quasi-vide. Solution : 2e cycle clean (`Remove-Item .nuxt/dist + .output`) + rebuild + redeploy avec `yes y |` (skip confirm interactif `.env diff`).
|
||||
|
||||
**Réflexe à intégrer** : après build, vérifier `grep -o "<un-fragment-de-modif>" .output/public/_nuxt/*.js | head` AVANT le deploy. Si 0 match → ne pas deploy, rebuild.
|
||||
|
||||
### Bug de communication à retenir
|
||||
|
||||
Jules a signalé "le logo n'a pas marché", "B2C pas renommé", "hamburger pas modifié" alors que le HTML en prod contenait bien les modifs (vérifié curl avec `?nc=$(date +%s)`). **Cause** : cache navigateur / service worker Nuxt. Réflexe à mettre en place pour /done de toute session web : si Jules dit "ça n'apparaît pas", vérifier curl en bypass cache AVANT de chercher un bug. Si match curl → demander hard refresh (Ctrl+Shift+R).
|
||||
|
||||
### Reste à faire (batch 3)
|
||||
|
||||
Voir `0 INBOX/PROMPTS/cascade-megaboum/REPRISE-aep-carto-fix-batch3.md` :
|
||||
- Bouton "+" → sélecteur 3 cartes (Entraide/Réseaux/Jobs)
|
||||
- Pop-up explication 5 axes Jobs (paragraphe par axe)
|
||||
- Pop-up Carte 1 visibilité (option à clarifier avec Jules)
|
||||
- GraphView Carte 1 (centres = hashtags, couche échelle activable) — gros chantier session dédiée
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
@@ -1054,57 +1003,3 @@ Incohérence entre `deploy.sh` (rsync vers `/opt/nav-carte/`) et `nav-carte.serv
|
||||
- **Caching API organisations** — actuellement re-fetch NocoDB à chaque render
|
||||
- **Full-text search côté client** — Fuse.js sur descriptions enrichies
|
||||
- **Mode offline / PWA** — manifest + service worker pour usage terrain
|
||||
|
||||
---
|
||||
|
||||
## Décisions structurantes (mémoire profonde)
|
||||
|
||||
> Archive des décisions structurantes du projet nav-carte (AEP V1/V2/Codev/Carte 3), déchargée hebdo depuis coordo-agent-dev. Tri chronologique inverse (plus récent en haut). Copies verbatim, pas de reformulation.
|
||||
|
||||
### 2026-W19 (décharge 2026-05-13)
|
||||
|
||||
- [W19 — 2026-05-08 19:21] **AEP nav-carte fix mobile batch1+2 LIVE + cause racine chatbot Carte1=Carte2 résolue** (~3h pilote direct, 2 batches successifs, 11 fichiers). **Cause racine identifiée** après 5 cycles de fix précédents infructueux : `/api/chatbot-reseaux` était **404 en prod** (jamais déployé). Le code source nav-carte était correct depuis le début. Rebuild + redeploy = bug résolu (vérifié curl POST sur les 2 endpoints, réponses distinctes). **Batch 1** : hamburger refondu (Jobs/Manifeste/Soutenir), FAB cœur jaune retiré, 3e onglet Graphe mobile sur agences, /a-propos refondue + scroll latéral fixé, page /manifeste créée (texte version page-carto-V1), MissionPopup générique avec slot/props (storageKey, title, ctaLabel) auto-show 1ère visite, filtres Jobs mobile repliables (toggle "Filtres [N]"), FicheModal/V2 décalage `top:76px` mobile pour ne plus mordre header. **Batch 2** : pop-up Carte 2 Réseaux AEP, logo header `Architecture d'Écologie Politique` en clair sur 2 lignes (logo-line-1/2 responsive), onglet "Plateformes B2C" → "Pour archi indépendants", intro pédagogique repliable Jobs (2 onglets / 3 tags / 5 axes éthiques), labels noms structures sur GraphView (D3 append text + halo via `paint-order: stroke`). **Pattern d'opération critique découvert** : Dropbox sync efface .output entre `npm run build` et `tar | ssh` du deploy.sh — 1er deploy batch 2 a uploadé un .output quasi-vide sans erreur visible (HTTP 200 trompeur). Réflexe : `grep "fragment-modif" .output/public/_nuxt/*.js | head` AVANT deploy. **Pattern de communication** : Jules a signalé "modifs pas faites" alors que HTML prod contenait bien les modifs (vérifié curl) — cache navigateur / service worker. Réflexe : si "ça apparaît pas", curl bypass cache AVANT chercher bug, puis demander hard refresh. Git : commit propre + push gitea/main (`yes y | bash deploy.sh` pour skip confirm interactif `.env diff`). Prompt batch 3 dans `0 INBOX/PROMPTS/cascade-megaboum/REPRISE-aep-carto-fix-batch3.md`.
|
||||
|
||||
- [W19 — 2026-05-07 17:18] **AEP Carte 3 "Trouver du taf" — T2 scoring 5 axes + T3/T4 LIVE** : 24 plateformes scorées (7✅/14⚠️/3❌), plateformes-taff.json prod, page /trouver-du-taf complète (filtres, grille 1 col, modal fiche, chatbot FAB Guide IA). Endpoints /api/chatbot-taff (import JSON statique + Mistral Small) + /api/chatbot-reseaux (keyword search 120 structures). ChatbotReseaux.vue créé (standalone Carte 2). useMarkdown.ts inline styles CSS-free. Onglet Jobs nav desktop. Bug dev : cache .nuxt corrompu par agent concurrent → bat rmdir+délai 12s. Chatbot séparation + markdown à vérifier en PROD (pas dev). Menu hamburger mobile Jobs manquant. Branche main.
|
||||
|
||||
- [W19 — 2026-05-07 01:11] **Codev MVP LIVRÉ en prod** — cascade M1→M5 complète (5 agents Sonnet), merge feat/codev-mvp→main, push Gitea. App entraide /codev live avec lock screen, formulaire, graphe D3 force-directed, annuaire table sticky, 2 modes matching (Solution tokenize direct + Alliance Jaccard), mode admin (DELETE fiche), QR code public, panel mobile bottom sheet. Décision build : TOUJOURS depuis C:\tmp\nav-build (Dropbox corrompt cache Vite = 500 prod). Algo fix critique : Solution compare textes besoin↔offre directement (ignore hashtags), évite que les 3 modes donnent le même graphe.
|
||||
|
||||
- [W19 — 2026-05-06 17:11] **MP-TAFF T1b scraping compléments DONE** — BrowserMCP utilisé pour débloquer Trustpilot (7 pages) + instao.fr + francemarches.com. `T1-output-plateformes.json` finalisé : 25 plateformes, 7 avec feedback Trustpilot. Signaux forts T2 : Archionline 2.4/5 (spam + permis PLU non conformes), hemea = courtier déguisé en MOE, TMA = meilleur ratio pros. Prochaine étape : **MP-TAFF T2 scoring 5 axes éthiques**.
|
||||
|
||||
- [W19 — 2026-05-06 13:30] **Cascade dispatchable Codev MVP livrée — 9 fichiers prêts à dispatcher en session Opus dédiée** : Cadrage `/orchestrateur` app entraide / co-développement Jules pour facilitation IRL jeudi 8/05 ~10 amis. 5 intersections tranchées : URL `/codev` (sous-route aep.trans-former.fr pas nouveau domaine), naming "Co-développement"/"Entraide entre pairs", mot de passe partagé `merci`, persistance NocoDB nouvelle table `codev_fiches` (carto vit hebdo, pas effacée), démo route publique `/codev/demo` (10 prénoms factices pour pitch univ). **Démêlage trinôme** (problème mathématique flaggé par Jules) : matching pair-only en MVP, trinômes émergent visuellement du D3 force-directed (triangles dans le graphe), pas de logique trinôme explicite codée — économie combinatoire majeure. 3 modes : Solution (besoin→offre asymétrique avec flèche), Alliance (besoin↔besoin symétrique), Surprise (offre↔offre symétrique). **Algo matching MVP simple** sans IA : Jaccard sur hashtags si présents, sinon Jaccard sur tokens FR (stop-words filtrés) seuil 0.15 — V2 embeddings si scaling. **Réutilisation pattern GraphView.vue** (~700 lignes existant) en simplifié (~200-300 lignes pour CodevGraph) — pas de greenfield. Cascade dans `codev-build/` (même pattern que `aep-communaute-build/`) : INDEX (table+décisions+statut) + META-PROMPT-OPUS (preflight+dispatch séquentiel+1 checkpoint deploy+format récap) + M1 (NocoDB table API + 3 endpoints + runtimeConfig) + M2 (lock+fiche+middleware auth skip /codev+/codev/demo) + M3 (CodevGraph D3 + page carto affichage seul) + M4 (matching 3 modes + boutons + animation force.alpha(0.5).restart()) + M5 split phase 1 (démo+build local+stop) + phase 2 (deploy prod après checkpoint Jules) + FEEDBACK-PASSES (10 risques pré-dispatch corrigés) + PHRASE-LANCEMENT (one-shot pour session Opus). **Patches en cours de session** : M1 fait création table NocoDB en autonomie via API (token NocoDB `e9rU...` déjà dans nav-carte/.env, endpoint `POST /api/v2/meta/bases/{baseId}/tables`), M5 phase 2 sync .env VPS automatiquement (ssh append + restart aep), règle d'or "couper M4/M5 si timing serré" retirée du META car Jules a confirmé scope OK. **1 seul checkpoint Jules** : entre M5 phase 1 (build local 200) et M5 phase 2 (deploy prod). Pattern méga-dispatch consécutif #5 (avant : Simulateur V2 30/04, Méga-cascade AEP V2 30/04, MP-TAFF cascade-megaboum 06/05, AEP V2 graphe PV2-5 micro-itérations 06/05). À dispatcher en session Opus dédiée (jauge propre, ~1h30 cascade attendue + ~5 min action manuelle Jules NocoDB).
|
||||
|
||||
- [W19 — 2026-05-06 21:30→01:45] **AEP V2 graphe interactif PV2-5b/e/f/g + chatbot v2 vivant + decouverte 2 repos imbriqués** : 4 commits vault `feat/aep-v2-cartobifurcation` (062337a sidebar+chatbot intégré, 2adffdf toggle Familles/Pratiques+popover famille, 5d7556a carte unifiée layers superposables+popover hashtag+lisibilité, e1ae1b9 popovers enrichis+FicheFamilleModal). 4 micro-itérations dispatch agent Sonnet en séquentiel, pilote commit lui-même chaque fois (pattern anti-hallucination établi après agent 1 a inventé hash 755d1ef). **Décisions design** : skip PV2-5c bicolores (8/120 structures = effet marginal), toggle PV2-5e exclusif fusionné en layers superposables PV2-5f (intuition Jules : séparation artificielle), Pratiques default OFF perf-friendly (174 noeuds + 640 liens si tout coché), FicheFamilleModal composant dédié réutilisable, skip définitions hashtags (pas de contenu source) → ligne générique "portée par N structures de M familles". **Découverte 2 repos git imbriqués** (vault parent ATIS-IPCJRA branche v2 + sous-repo nav-carte/ branche v1.1 distincte) → expliquait les "hallucinations" branche des agents. Notée dans `ATIS-Dev.md` section "Pièges connus" + réflexe pilote `git rev-parse --show-toplevel` au démarrage. **Chatbot v2** : endpoint v1→v2 dans ChatbotPlaceholder.vue (commit sous-repo 5878c56), vectorize-v2.js renommé en .cjs (incompat ESM type=module), payload Mistral fixed `inputs`→`input`, 120 embeddings générés (3.5MB embeddings-v2.json gitignored), patch vectorSearch.ts process.cwd() au lieu d'import.meta.url (bug Nitro bundle). **Rotation clé Mistral** : nouvelle clé `PXsPUhk...` notée _System/API-credentials.md, appliquée local .env + VPS /opt/aep/.env (backup .env.bak.before-rotation-20260506) + restart aep + smoke test prod chatbot v1 OK. **Doc features graphe** créée : `aep-communaute-build/PV2-5-FEATURES-GRAPHE.md` (briefing agent qui découvre en 30 sec). **Backlog différé** : PV2-5d sous-noeuds projets emblématiques (perf-critique 480 noeuds), définitions hashtags (session écriture éditoriale Jules), décision repo imbriqués (intersection stratégique demain). **Test live chatbot v2 bloqué** par lock Dropbox sur .nuxt/dev → prompt cloture demain `PV2-5h-test-chatbot-v2-local.md`.
|
||||
|
||||
- [W19 — 2026-05-06 02:30] **MP-TAFF V2 cadré + prompt scraping autonome séparé + rename atis-humain** : Brainstorm divergent Jules pour Carte 3 AEP "Trouver du taf en archi". 5 axes scoring éthique AEP validés (Rémunération / Transparence / Pratiques pro / Écologie / Matching) avec définitions + critères + échelles ✅⚠️❌ — c'est le différenciant central vs annuaires neutres. Décisions verrouillées : freelance only V1 (70% archis indé = cible la plus en galère), IA applique scoring (critères validés une fois = pas de validation fiche par fiche), onglet `aep.trans-former.fr` (pas sous-domaine), branche parallèle `feat/aep-taff-v1` (pas attendre merge V2), SEO reporté V2 (skill `/seo-page-aep` à créer). MP-TAFF V2 ~430 lignes avec 2 tours auto-feedback (table décision tag global, format desc IA 5 sections, §risque juridique nominatif, calibrage chatbot 3 questions, préflight conflit branche V2 + i18n). **MP-TAFF-T1-scraping-autonome.md créé** (~270 lignes) — sortable sur PC séparé pour parallélisation pendant qu'ATIS Dev bosse T0/T2/T3+. Pattern routing scraping documenté : Jina d'abord → crawl4ai SPA → BrowserMCP RGPD/auth → manuel flag. Forums commu intégrés : Team.Archi, Reddit r/Architecture FR, presse pro (Le Moniteur, AMC, D'A). Output JSON structuré consommable par T2. 🔒1 simplifié = récap scope synthétique (10 min Jules). Backlog cascade : MP-MENTOR (carte 4 entraide), MP-CROSS (n8n + GitHub OS) restent prêts. **Rename `tara` → `atis-humain`** : skill renommée, routing patché dans `atis-archi.md` ligne 390 et `ATIS-agents-specialises.md` ligne 29. Anciennes refs à Tara la personne (Mediathèque, done.md, ton-jules.md) inchangées.
|
||||
|
||||
- [W19 — 2026-05-05] **AEP V2 PV2-4+5+8 DONE + vue graphique D3** : PV2-4 (887 edges, 480 projets, reseaux-bifurcation.json 847KB). PV2-5 UI (NavMapV2, HashtagFilter, IntentionBanner, FicheModalV2, palette 5 familles, geocodage 118/120, GraphView.vue D3 force-directed). PV2-8 RAG (chatbot-v2.post.ts + vectorize-v2.js). Fixes UI : onglets outremer desktop, sidebar scroll, chips colorees, hashtags repliables, F6 filtre, intention overlay localStorage. EDITO-V2.md cree. 13 commits feat/aep-v2-cartobifurcation. 🔒 PV2-5 checkpoint visuel Jules en attente.
|
||||
|
||||
### 2026-W18 (décharge 2026-05-13)
|
||||
|
||||
- [W18 — 2026-05-03] **AEP V2 PV2-2ter DONE** : 10 emails récupérés. Volet A F2 (amaco/LTE), F4 (toitsdechoix/HPO/HabiterAuvergne/EmmanuelleDucos). Confirmed not public : F3 (LALCA/Sentiers/AOA/METALAB), F4 (unitoit/atelier15/a-tipic/HPF/atcoop). Blocages : rfcp.fr GravityView JS, a-tipic HTTP 400. Volet B F6 : 7 flags + 4 emails (Forensic/Centrala/NBL/Assemble) + 1 new fiche Collectif Etc (contact@collectifetc.com). Seed 122 fiches. Commit `7ce8e12`.
|
||||
|
||||
- [W18 — 2026-05-03] **AEP V2 PV2-2 F1 DONE** : 26 fiches réemploi & filières (14 V1 + 12 nouvelles). Nouvelles : Cycle Up (contact@cycle-up.fr), Backacia (form), Mobius (contact@mobius-corp.com), AD VITAM MATERIAL (reemploi@embuild.be), Cirkla (c/o insitu), CANCAN (contact@collectifcancan.fr), HArquitectes (harquitectes@harquitectes.com), isla (press@isla-architects.com), jdviv BE (EUmies 2026 co-winner), SalvoWEB, B+L Architectes, REFAIR/BDR. 6/12 emails high. Hashtag nouveau proposé : #amo-reemploi (AMO/diagnostic PEMD spécialisés). BrowserMCP off toute la session → Jina only (Reuse Foundation non scrapée, jdviv URL à confirmer). Commit `656cc2d`. **PV2-2 5/5 familles DONE.** → Reste PV2-3+PV2-4.
|
||||
|
||||
- [W18 — 2026-05-03] **AEP V2 PV2-2 F2 DONE** : 36 fiches frugalité & low-tech (22 V1 + 14 nouvelles). Nouvelles : Lacaton&Vassal (Pritzker 2021), Kéré Architecture (mail@kerearchitecture.com), Anna Heringer, CRATerre (secretariat@craterre.org), Les Grands Ateliers, AsTerre (secretariat@asterre.org), RFCP, EnvirobatBDM, NUNC, LAPS, Dorodango, BEES, amàco (contact@amaco.org), Lehm Ton Erde. 4/14 emails high conf. Sources productrices : AsTerre annuaire (19 agences archi identifiées), Pritzker (2 nouvelles), frugalite.org réseau. Blocages : RFCP annuaire JS, lehmtonerde.at 422, OFF laureats non scraped, BrowserMCP instable (3 décos). Commits `8808a35`+`301c3be`. → Reste F1 Réemploi.
|
||||
|
||||
- [W18 — 2026-05-03] **AEP V2 PV2-2 F3 DONE** : 22 fiches architecture sociale & précarités (11 V1 + 11 nouvelles). Nouvelles : PEROU, Plateau Urbain (SCIC), Bellastock, ASF France, Rural Studio, Forensic Architecture, Collectif Parenthèse, WoMa, Fab City Grand Paris, CivicWise. 6/11 emails high conf (PEROU·Bellastock·Rural Studio·Parenthèse·WoMa·CivicWise). Sources : Quatorze /partenaires-new (meilleur hub), YWC lieux (Grands Voisins + Coco Velten), A&P filtres. 4 multi-famille (Bellastock F3+F5, Parenthèse F3+F4, WoMa F3+F4, YWC F3+F4+F5). Commit `d2028f5`. → Reste F1 + F2.
|
||||
|
||||
- [W18 — 2026-05-02 23:23] **AEP V2 PV2-2 F4 DONE** : 20 nouvelles fiches collectifs/écolieux/AMO via Jina (BrowserMCP déco → pivot Jina). 11/20 emails high conf. Structures-clés : RAHP, HPF, Habicoop, Hab-Fab SCIC, Regain SCIC, Coopérative Oasis, Mietshäuser Syndikat. 9 contacts partiels (tel/form) à compléter BrowserMCP. Commit `f54afe3`.
|
||||
|
||||
- [W18 — 2026-05-02 19:51] **AEP V2 PV2-2 F5 DONE** : 15 fiches urbanisme transition via BrowserMCP. 7 emails high (CLER/TEPOS/Coloco/Bas Smets/EP/FNAU/Atelier Georges). Commit `56c93eb`.
|
||||
|
||||
- [W18 — 2026-05-02 18:17] **C3 smoke test + PV2-1 scrape DONE** : C3 = 2 bugs (P0 algo-config.json 404, P1 redirect 301 manquant) + rapport `C3-RAPPORT.md`. PV2-1 = 5/5 emails trouvés via BrowserMCP (Opalis/Frugalité/Quatorze/Tepop/Transition France), commit 6df5b84. Stack BrowserMCP validé pour batch PV2-2. Patcher P0+P1 avant merge master simulateur.
|
||||
|
||||
- [W18 — 2026-04-30 12:00] **AEP Cascade V2 Phase A2+A3 figées + PILOTE-V2 doc pilote vivant** : Session "AEP COMMU V3 suite" /atis-archi puis /atis-dev. SPEC-V2-FEEDBACK-DEV.md livré (faisabilité pipeline 3 passes, email cascade 5 niveaux estim 65-80%, reclaim JWT HS256 30j single-use, grain JSON suffisant si desc_longue 600+ + 3 sources, branches 2 dédiées, pre-flights standardisés, fix BOM). PILOTE-V2.md créé comme **source de vérité vivante** à la racine `aep-communaute-build/` (Jules pilote depuis ce fichier ; tout Opus suivant le lit en premier). 13 prompts PV2-X dans `0 INBOX/PROMPTS/aep-v2-cartobifurcation/` (README + PV2-0 preflight + PV2-1 scrape test 5 fiches + PV2-2 5 agents recherche par famille en parallèle (idée Jules : recoupement multi-famille = signal politique transversalité) + PV2-2bis recoupement + PV2-3 passe2 analyse + PV2-4 passe3 croisements + PV2-5 refonte UI + PV2-6 reclaim + PV2-7 badges statut + PV2-8 RAG coexistence v1+v2 + PV2-9 bandeau regards d'ailleurs + PV2-10 E2E build + PV2-11 batch emails + tri DOM-TOM). NEXT-SESSION-PROMPT-V3.md créé pour relais. **Amendements Jules sur SPEC-V2** : famille 1 "Réemploi & matière" → "Réemploi & filières", AMO ajouté famille 4 (Tepop/Hab-Fab/Habicoop), famille 5 Urbanisme transition gardée fermement (cibler scrape agressif), centres ressources DOM-TOM → carte ressources existante (sauf Caribois praticien direct), pas de cap fiches sur agents recherche, stratégie snowball depuis nodes établis (Frugalité, Opalis, A&P) + crawl collaborateurs/influences/prix/distinctions. Sessions batch nocturnes dimensionnées Claude Max 5h Opus. PV2-0 partiel exécuté : branche `feat/aep-v2-cartobifurcation` créée depuis origin/main (tracking unset = anti-push main accidentel) + BOM UTF-8 retiré de `nav-carte/deploy.sh`. PV2-0 effectif (checkout + structure `nav-carte/V2-cascade/` + hook pre-commit no-BOM + sources-par-famille.md) à faire prochaine session après commit/stash des 830 fichiers pending sur `feat/aep-website-v1.1`.
|
||||
|
||||
- [W18 — 2026-04-30] **Cascade MEGABOUM opérationnelle — 4 MP rédigés + cockpit lisible** : `0 INBOX/PROMPTS/cascade-megaboum/` créé avec `00-COCKPIT-CASCADE.md` (index lisible en 3 min par tout Opus dispatché, format différent de la mégaspec — celle-ci reste lecture profonde optionnelle). 4 méga-prompts prêts à dispatcher : **MP-TAFF** (app trouver du taf B2C/B2B/appels publics, ~5h, étend `aep-communaute-build/`), **MP-MENTOR** (app mentorat-entraide M7-M, ~5h, post-TAFF), **MP-CROSS** (pipeline cross-posting n8n LinkedIn+Substack+Listmonk+@aep.politique + GitHub OS publish skills/lightrag/vps-kit, ~5h, parallèle), **MP-DESIGN** (création agent atis-design, prompt court ~1h via /create-agent + scrape Prisme.one). Brief archive `MP10-manifeste-aep-INFO-BRIEF.md` (chantier déjà lancé par Jules). Chaque MP démarre par CHECKPOINT 0 réflexion faisabilité (l'Opus lit, propose, attend OK Jules avant dispatch). Backlog explicite : page-cerveau Astro from scratch, méga-RAG FRACAS vague 1, atis-philosophe, frontend-slides, rename atis-humain (P1 30 min), Insta @julesneny n8n (Q3). LightRAG VPS DOWN **déclassé** : pas bloqueur P0 semaine si on ne fait pas méga-RAG, devient bloqueur quand on attaquera RAG. Cadence : Jules pilote au fil des jours, 1-2 MP/jour, 2 clusters max simultanés.
|
||||
|
||||
- [W18 — 2026-04-30 02:46] **Méga-cascade V2 AEP Phase A1 figée + 3 agents background livrés** : Session Opus orchestrateur "AEP commu V2". 9 intersections tranchées par Jules en un message (5 familles fusion F4+F5, UX vignette + template carte 1, scope FR+Europe francophone + capture incidente régénératifs hors scope, articulation pensées<->structures reportée V2, passe profonde GO, email champ soft, charte reportée, filtre échelle drop, A3 absorbé A1). SPEC-V2.md figée. 3 agents Sonnet dispatchés en parallèle background : VOIE 2 V1.1 nav-carte (4 commits `feat/aep-v1.1-nav-carte` basée sur feat/aep-pratiques-regeneratives car main pré-V1 ; PA1 DOM-TOM pattern desktop 2 onglets, PA3 bouton Proposer contextuel, PA5 chatbot pratiques régé Mistral, 6/6 bugs E2E M1-M3+L1-L3 corrigés, build Nuxt OK), VOIE 3 website (1 commit `feat/aep-website-v1.1` e95f693 ; PB1 hamburger 4 entrées + stubs /manifeste /ressources /signaler ; **/!\ livré sur renovation-energetique.trans-former.fr - website pro, pas aep.trans-former.fr - à clarifier prochaine session**), PASSE PROFONDE (52 pratiques régé + 99 ressources institutionnelles analysés ; 5 familles confirmées avec garde-fous F5 ; 46 hashtags ; 7 cas limites + 4 hors-grille type "mouvement-manifeste" potentiel ; **226 acteurs candidats enrichissement carte ressources : P1=56 fiches urgentes dont 30 CAUE manquants top dpts + 4 CAUE DOM-TOM + 9 MA régionales + 2 CROA DOM ; constat critique : 0 DOM-TOM + 6/92 CAUE dans carte ressources actuelle**). 5 questions stratégiques remontées pour Phase A2 (Q-PP1 5 vs 6 familles, Q-PP2 type mouvement-manifeste, Q-PP3 F5 différée passe 2, Q-PP4 double-référencement KEBATI/AQUAA/RBD/Envirobat, Q-V3 site cible hamburger). NEXT-SESSION-PROMPT.md pré-écrit pour reprise propre Phase A2 /atis-dev. Pattern méga-dispatch consécutif #4. Tokens : 130k orchestrateur + ~451k délégués sous-agents.
|
||||
|
||||
- [W18 — 2026-04-30 01:42] **Méga-cascade V2 AEP cadrée** : `MEGA-V2.md` master orchestration 3 voies créé. VOIE 1 = V2 conceptuelle (refonte carte écosystème AEP en carte des réseaux de bifurcation, 5-6 familles éditoriales, reclaim email magique, pipeline IA cascade 3 passes, ~75-100 fiches). VOIE 2 = V1.1 nav-carte (items 1+2+4+5+8 ; item 3 absorbé par VOIE 1). VOIE 3 = website astro-site (hamburger + manifeste + ressources). Décisions Jules : `/atis-archi` pilote la spec V2 conceptuelle Phase A1, `/atis-dev` en relai Phase A2. Apports critiques : champ email obligatoire dans le scrape (sans email = pas de reclaim), passe profonde sur fiches existantes (~52+80) pour faire remonter hashtags sous-familles, item 4 (filtre échelle) à questionner, A3 absorbable dans A1, page Manifeste à ajouter au hamburger website. Phrase d'amorce + effort `high` recommandé pour la session Opus dédiée demain. PHRASE-LANCEMENT-OPUS-V2.md marqué SUPERSEDED. 2 briefs INBOX (V2-BRIEF-AGENT-OPUS + V2-RECAP-PROJET) déplacés dans aep-communaute-build/.
|
||||
|
||||
- [W18 — 2026-04-29 11:48] **AEP scrape P1-P7 FAIT** : BrowserMCP (P1 architecture-precarites.fr : 200+ projets, 5 catégories) + Jina (P4 vegetal-e ✅, P6 colorado-architecture ✅, P7 karibati ✅, P3 caribois ✅). P2 archidev bot-protégé + P5 envirobat 422 → consultation manuelle. `scrape-browsermcp.json` créé (7 entrées). Email auteurs architecture-precarites.fr envoyé par Jules. INCLURE : vegetal-e (5/8), envirobat-RE (7/8), karibati (5/8). EXCLURE : caribois (2/8), colorado (3/8).
|
||||
|
||||
- [W18 — 2026-04-29] **AEP V1 E2E PASS** : 5/5 scénarios OK (3 mobile, 3 laptop). Branche `feat/aep-pratiques-regeneratives` prête à merger main. `E2E-RESULTS.md` créé. Bugs mineurs capturés : M1 chips a11y + M2 reset searchbox + M3 floating button + L1 redirection.
|
||||
|
||||
- [W18 — 2026-04-29 08:11] **AEP V1 LIVRÉE** : 52 fiches prod (`aep.trans-former.fr/pratiques-regeneratives`), 12 commits feat/. V1.1 mode divergent cadrée (8 items brain-dump Jules).
|
||||
|
||||
@@ -330,3 +330,91 @@ Pattern à généraliser :
|
||||
4. Normalize(tags) → taxonomie contrôlée
|
||||
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`.
|
||||
|
||||
@@ -22,12 +22,19 @@
|
||||
|
||||
<!-- ── 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">
|
||||
<NuxtLink
|
||||
to="/outils"
|
||||
class="nav-tab"
|
||||
:class="{ 'nav-tab--active': route.path === '/outils' }"
|
||||
>
|
||||
Outils
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
to="/"
|
||||
class="nav-tab"
|
||||
:class="{ 'nav-tab--active': route.path === '/' }"
|
||||
>
|
||||
Écosystème Entraide Architecture
|
||||
Écosystème Entraide
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
to="/agences"
|
||||
@@ -53,7 +60,7 @@
|
||||
<NuxtLink
|
||||
to="/media"
|
||||
class="nav-tab"
|
||||
:class="{ 'nav-tab--active': route.path === '/media' }"
|
||||
:class="{ 'nav-tab--active': route.path.startsWith('/media') }"
|
||||
>
|
||||
recherche-média
|
||||
</NuxtLink>
|
||||
@@ -93,6 +100,13 @@
|
||||
|
||||
<!-- Actions droite -->
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<NuxtLink
|
||||
to="/manifeste"
|
||||
class="px-3 py-1.5 rounded-lg text-sm font-medium transition-all hover:opacity-80 hidden md:inline-flex items-center gap-1"
|
||||
:style="route.path === '/manifeste' ? 'color: var(--nav-primary-solid); font-weight: 700;' : 'color: var(--nav-text-muted);'"
|
||||
>
|
||||
Manifeste
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
to="/a-propos"
|
||||
class="px-3 py-1.5 rounded-lg text-sm font-medium transition-all hover:opacity-80 hidden md:inline-flex items-center gap-1"
|
||||
@@ -107,52 +121,14 @@
|
||||
>
|
||||
Signaler
|
||||
</NuxtLink>
|
||||
<!-- Proposer — popover 3 choix -->
|
||||
<div class="hidden sm:block relative" ref="proposerAnchor" data-proposer-popover>
|
||||
<button
|
||||
@click="proposerOpen = !proposerOpen"
|
||||
class="px-3 py-1.5 rounded-lg text-sm font-semibold transition-all hover:opacity-80 inline-flex items-center gap-1"
|
||||
style="background: var(--nav-accent); color: var(--nav-text);"
|
||||
aria-label="Proposer une contribution"
|
||||
>
|
||||
+ Proposer
|
||||
</button>
|
||||
<div
|
||||
v-if="proposerOpen"
|
||||
class="absolute right-0 top-full mt-1 rounded-lg shadow-lg min-w-[240px] py-1"
|
||||
style="background: var(--nav-surface); border: 1px solid var(--nav-bg-alt); z-index: 9999;"
|
||||
>
|
||||
<NuxtLink
|
||||
to="/contribuer"
|
||||
class="flex items-center justify-between px-4 py-2.5 text-sm font-medium transition-opacity hover:opacity-70"
|
||||
style="color: var(--nav-text);"
|
||||
@click="proposerOpen = false"
|
||||
>
|
||||
<span>Fiche Entraide <span style="color: var(--nav-text-muted); font-weight: 400; font-size: 0.7rem; display: block;">Carte 1 — Écosystème archi</span></span>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0; color: var(--nav-text-muted);"><polyline points="9 18 15 12 9 6"/></svg>
|
||||
</NuxtLink>
|
||||
<div style="height: 1px; background: var(--nav-bg-alt); margin: 2px 0;"></div>
|
||||
<NuxtLink
|
||||
to="/contribuer-reseau"
|
||||
class="flex items-center justify-between px-4 py-2.5 text-sm font-medium transition-opacity hover:opacity-70"
|
||||
style="color: var(--nav-text);"
|
||||
@click="proposerOpen = false"
|
||||
>
|
||||
<span>Réseau / collectif <span style="color: var(--nav-text-muted); font-weight: 400; font-size: 0.7rem; display: block;">Carte 2 — Réseaux AEP</span></span>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0; color: var(--nav-text-muted);"><polyline points="9 18 15 12 9 6"/></svg>
|
||||
</NuxtLink>
|
||||
<div style="height: 1px; background: var(--nav-bg-alt); margin: 2px 0;"></div>
|
||||
<NuxtLink
|
||||
to="/contribuer-job"
|
||||
class="flex items-center justify-between px-4 py-2.5 text-sm font-medium transition-opacity hover:opacity-70"
|
||||
style="color: var(--nav-text);"
|
||||
@click="proposerOpen = false"
|
||||
>
|
||||
<span>Plateforme jobs <span style="color: var(--nav-text-muted); font-weight: 400; font-size: 0.7rem; display: block;">Carte 3 — Jobs archi</span></span>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0; color: var(--nav-text-muted);"><polyline points="9 18 15 12 9 6"/></svg>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Proposer une ressource (5 onglets) -->
|
||||
<NuxtLink
|
||||
to="/proposer"
|
||||
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);"
|
||||
>
|
||||
+ Proposer
|
||||
</NuxtLink>
|
||||
|
||||
<!-- Toggle dark mode -->
|
||||
<button
|
||||
@@ -174,43 +150,21 @@
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Mobile : contribuer icône → popover -->
|
||||
<div class="sm:hidden relative" data-proposer-popover>
|
||||
<button
|
||||
@click="proposerOpen = !proposerOpen"
|
||||
class="p-2 rounded-lg"
|
||||
style="background: var(--nav-accent); color: var(--nav-text);"
|
||||
title="Contribuer"
|
||||
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">
|
||||
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div
|
||||
v-if="proposerOpen"
|
||||
class="absolute right-0 top-full mt-1 rounded-lg shadow-lg min-w-[220px] py-1"
|
||||
style="background: var(--nav-surface); border: 1px solid var(--nav-bg-alt); z-index: 9999;"
|
||||
>
|
||||
<NuxtLink to="/contribuer" class="flex items-center justify-between px-4 py-2.5 text-sm font-medium transition-opacity hover:opacity-70" style="color: var(--nav-text);" @click="proposerOpen = false">
|
||||
<span>Fiche Entraide</span>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0; color: var(--nav-text-muted);"><polyline points="9 18 15 12 9 6"/></svg>
|
||||
</NuxtLink>
|
||||
<div style="height: 1px; background: var(--nav-bg-alt); margin: 2px 0;"></div>
|
||||
<NuxtLink to="/contribuer-reseau" class="flex items-center justify-between px-4 py-2.5 text-sm font-medium transition-opacity hover:opacity-70" style="color: var(--nav-text);" @click="proposerOpen = false">
|
||||
<span>Réseau / collectif</span>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0; color: var(--nav-text-muted);"><polyline points="9 18 15 12 9 6"/></svg>
|
||||
</NuxtLink>
|
||||
<div style="height: 1px; background: var(--nav-bg-alt); margin: 2px 0;"></div>
|
||||
<NuxtLink to="/contribuer-job" class="flex items-center justify-between px-4 py-2.5 text-sm font-medium transition-opacity hover:opacity-70" style="color: var(--nav-text);" @click="proposerOpen = false">
|
||||
<span>Plateforme jobs</span>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0; color: var(--nav-text-muted);"><polyline points="9 18 15 12 9 6"/></svg>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Mobile : proposer icône -->
|
||||
<NuxtLink
|
||||
to="/proposer"
|
||||
class="sm:hidden p-2 rounded-lg"
|
||||
style="background: var(--nav-accent); color: var(--nav-text);"
|
||||
title="Proposer une ressource"
|
||||
aria-label="Proposer"
|
||||
>
|
||||
<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"/>
|
||||
</svg>
|
||||
</NuxtLink>
|
||||
|
||||
<!-- Hamburger mobile (lg:hidden) — toujours en dernier à droite -->
|
||||
<div class="lg:hidden relative">
|
||||
<!-- Menu déroulant — toutes largeurs (les onglets n'y apparaissent qu'en dessous de 1024px) ; porte « Soutenir » -->
|
||||
<div class="relative">
|
||||
<button
|
||||
@click="hamburgerOpen = !hamburgerOpen"
|
||||
class="p-2 rounded-lg transition-all hover:opacity-80"
|
||||
@@ -231,15 +185,16 @@
|
||||
style="background: var(--nav-surface); border: 1px solid var(--nav-bg-alt); z-index: 9999;"
|
||||
@click="hamburgerOpen = false"
|
||||
>
|
||||
<NuxtLink to="/" class="block px-4 py-2.5 text-sm font-medium transition-opacity hover:opacity-70" :style="route.path === '/' ? 'color: var(--nav-primary-solid); font-weight: 700;' : 'color: var(--nav-text);'">Écosystème Entraide Architecture</NuxtLink>
|
||||
<NuxtLink to="/agences" class="block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" :style="route.path === '/agences' ? 'color: var(--nav-primary-solid); font-weight: 700;' : 'color: var(--nav-text);'">Réseaux AEP</NuxtLink>
|
||||
<NuxtLink to="/trouver-du-taf" class="block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" :style="route.path === '/trouver-du-taf' ? 'color: var(--nav-primary-solid); font-weight: 700;' : 'color: var(--nav-text);'">Jobs</NuxtLink>
|
||||
<NuxtLink to="/media" class="block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" :style="route.path === '/media' ? 'color: var(--nav-primary-solid); font-weight: 700;' : 'color: var(--nav-text);'">recherche-média</NuxtLink>
|
||||
<NuxtLink to="/codev" class="block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" :style="route.path.startsWith('/codev') ? 'color: var(--nav-primary-solid); font-weight: 700;' : 'color: var(--nav-text);'">Codev</NuxtLink>
|
||||
<div style="height: 1px; background: var(--nav-bg-alt); margin: 4px 0;"></div>
|
||||
<NuxtLink to="/outils" class="lg:hidden block px-4 py-2.5 text-sm font-medium transition-opacity hover:opacity-70" :style="route.path === '/outils' ? 'color: var(--nav-primary-solid); font-weight: 700;' : 'color: var(--nav-text);'">Outils</NuxtLink>
|
||||
<NuxtLink to="/" class="lg:hidden block px-4 py-2.5 text-sm font-medium transition-opacity hover:opacity-70" :style="route.path === '/' ? 'color: var(--nav-primary-solid); font-weight: 700;' : 'color: var(--nav-text);'">Écosystème Entraide</NuxtLink>
|
||||
<NuxtLink to="/agences" class="lg:hidden block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" :style="route.path === '/agences' ? 'color: var(--nav-primary-solid); font-weight: 700;' : 'color: var(--nav-text);'">Réseaux AEP</NuxtLink>
|
||||
<NuxtLink to="/trouver-du-taf" class="lg:hidden block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" :style="route.path === '/trouver-du-taf' ? 'color: var(--nav-primary-solid); font-weight: 700;' : 'color: var(--nav-text);'">Jobs</NuxtLink>
|
||||
<NuxtLink to="/media" class="lg:hidden block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" :style="route.path.startsWith('/media') ? 'color: var(--nav-primary-solid); font-weight: 700;' : 'color: var(--nav-text);'">recherche-média</NuxtLink>
|
||||
<NuxtLink to="/codev" class="lg:hidden block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" :style="route.path.startsWith('/codev') ? 'color: var(--nav-primary-solid); font-weight: 700;' : 'color: var(--nav-text);'">Codev</NuxtLink>
|
||||
<div class="lg:hidden" style="height: 1px; background: var(--nav-bg-alt); margin: 4px 0;"></div>
|
||||
<NuxtLink to="/manifeste" class="block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" :style="route.path === '/manifeste' ? 'color: var(--nav-primary-solid); font-weight: 700;' : 'color: var(--nav-text-muted);'">Manifeste</NuxtLink>
|
||||
<NuxtLink to="/a-propos" class="block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" style="color: var(--nav-text-muted);">À propos</NuxtLink>
|
||||
<a href="https://liberapay.com/trans-former.fr/donate" target="_blank" rel="noopener noreferrer" class="block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" style="color: var(--nav-text-muted);">Soutenir →</a>
|
||||
<a href="https://liberapay.com/trans-former.fr/donate" target="_blank" rel="noopener noreferrer" class="block mx-3 my-2 px-4 py-2 rounded-lg text-sm font-semibold text-center transition-opacity hover:opacity-80" style="background: var(--nav-accent); color: var(--nav-text);">Soutenir</a>
|
||||
<NuxtLink to="/signaler" class="block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" style="color: var(--nav-text-muted);">Signaler</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
@@ -251,9 +206,6 @@
|
||||
<NuxtPage />
|
||||
</div>
|
||||
|
||||
<!-- Bandeau bas — transparence IA + soutien + compteurs semaine -->
|
||||
<BandeauBas />
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -264,31 +216,6 @@ const route = useRoute()
|
||||
const hamburgerOpen = ref(false)
|
||||
watch(() => route.path, () => { hamburgerOpen.value = false })
|
||||
|
||||
// ── Popover "+ Proposer" ─────────────────────────────────────────────────
|
||||
const proposerOpen = ref(false)
|
||||
const proposerAnchor = ref<HTMLElement | null>(null)
|
||||
|
||||
function onClickOutsideProposer(e: MouseEvent) {
|
||||
// Ferme si le clic est hors de tout élément portant data-proposer-popover
|
||||
const target = e.target as HTMLElement
|
||||
if (!target.closest('[data-proposer-popover]')) {
|
||||
proposerOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(proposerOpen, (open) => {
|
||||
if (open) {
|
||||
// Délai court pour ne pas attraper le clic d'ouverture lui-même
|
||||
setTimeout(() => document.addEventListener('click', onClickOutsideProposer, true), 10)
|
||||
} else {
|
||||
document.removeEventListener('click', onClickOutsideProposer, true)
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', onClickOutsideProposer, true)
|
||||
})
|
||||
|
||||
// ── Dark mode ─────────────────────────────────────────────────────────────
|
||||
const isDark = ref(false)
|
||||
|
||||
|
||||
+32
-16
@@ -3,28 +3,44 @@
|
||||
@tailwind utilities;
|
||||
|
||||
/* ── Palette NAV V2 — Sobre institutionnel ─────────────────────────────── */
|
||||
/* --nav-* sont désormais des ALIAS vers les rôles @transformer/ui (tokens.css
|
||||
+ theme/aep.css, chargés avant ce fichier dans nuxt.config.ts).
|
||||
--nav-primary-raw, --nav-primary-solid et --nav-text-on-primary restent des
|
||||
valeurs PROPRES à nav-carte (non aliasées) :
|
||||
- le DS n'a pas de triplet RGB (--nav-primary-raw) ;
|
||||
- le --text du DS en sombre (#e8ecf5) ne correspond pas à la teinte bleu
|
||||
nuit voulue ici pour --nav-primary-solid (#c8d2f0) ;
|
||||
- --nav-text-on-primary sert de texte de contraste à la fois sur
|
||||
--nav-primary (translucide, alias DS) ET sur --nav-primary-solid (opaque,
|
||||
propre à nav-carte) — deux fonds dont la légèreté s'inverse en sombre
|
||||
(translucide ≈ fond sombre, solid = lavande claire). Le rôle --on-primary
|
||||
du DS ne colle qu'au premier cas ; l'aliaser cassait le contraste du AEP
|
||||
logo, des boutons trouver-du-taf, BandeauBas, SimulateurFeature,
|
||||
FicheDetail (texte clair sur --primary-solid clair en sombre — testé au
|
||||
pixel le 25/09, illisible). Gardé littéral = identique à la prod avant B3.
|
||||
Idem --nav-accent-soft, sans équivalent DS. (B3-M0, 2026-09-25) */
|
||||
:root {
|
||||
/* Fonds */
|
||||
--nav-bg: #f8f6f1;
|
||||
--nav-bg-alt: #eee9df;
|
||||
--nav-surface: #ffffff;
|
||||
--nav-bg: var(--bg);
|
||||
--nav-bg-alt: var(--bg-alt);
|
||||
--nav-surface: var(--surface);
|
||||
|
||||
/* Bleu nuit — 60% d'opacité sur les surfaces */
|
||||
--nav-primary-raw: 26, 34, 56;
|
||||
--nav-primary: rgba(26, 34, 56, 0.6);
|
||||
--nav-primary: var(--primary);
|
||||
--nav-primary-solid: #1a2238;
|
||||
|
||||
/* Accent safran */
|
||||
--nav-accent: #f5b342;
|
||||
--nav-accent: var(--accent);
|
||||
--nav-accent-soft: rgba(245, 179, 66, 0.85);
|
||||
|
||||
/* Texte */
|
||||
--nav-text: #1a2238;
|
||||
--nav-text-muted: rgba(26, 34, 56, 0.55);
|
||||
--nav-text: var(--text);
|
||||
--nav-text-muted: var(--text-muted);
|
||||
--nav-text-on-primary: #f8f6f1;
|
||||
|
||||
/* Typographie */
|
||||
--nav-font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
--nav-font: var(--font-body);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
@@ -58,25 +74,25 @@
|
||||
|
||||
/* Focus ring accessibilité */
|
||||
*:focus-visible {
|
||||
outline: 2px solid var(--nav-accent);
|
||||
outline: 2px solid var(--focus-ring);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* ── Dark mode ──────────────────────────────────────────────────────────── */
|
||||
.dark {
|
||||
--nav-bg: #111520;
|
||||
--nav-bg-alt: #1e2538;
|
||||
--nav-surface: #1a2238;
|
||||
--nav-bg: var(--bg);
|
||||
--nav-bg-alt: var(--bg-alt);
|
||||
--nav-surface: var(--surface);
|
||||
|
||||
--nav-primary-raw: 200, 210, 240;
|
||||
--nav-primary: rgba(200, 210, 240, 0.15);
|
||||
--nav-primary: var(--primary);
|
||||
--nav-primary-solid: #c8d2f0;
|
||||
|
||||
--nav-accent: #f5b342;
|
||||
--nav-accent: var(--accent);
|
||||
--nav-accent-soft: rgba(245, 179, 66, 0.85);
|
||||
|
||||
--nav-text: #e8ecf5;
|
||||
--nav-text-muted: rgba(200, 210, 240, 0.55);
|
||||
--nav-text: var(--text);
|
||||
--nav-text-muted: var(--text-muted);
|
||||
--nav-text-on-primary: #111520;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,488 +0,0 @@
|
||||
<template>
|
||||
<!-- ═══════════════════════════════════════ BANDEAU BAS ═══════════════════ -->
|
||||
<!-- DESKTOP uniquement (≥1024px) — mobile a le FAB séparé -->
|
||||
<footer
|
||||
v-if="!isMobile"
|
||||
ref="bandeauEl"
|
||||
class="bandeau-bas shrink-0"
|
||||
:class="{ 'bandeau-collapsed': isCollapsed }"
|
||||
aria-label="Informations projet AEP"
|
||||
@mouseenter="onMouseEnter"
|
||||
@mouseleave="onMouseLeave"
|
||||
>
|
||||
<!-- Contenu plein -->
|
||||
<div class="bandeau-inner" :class="{ 'bandeau-inner--hidden': isCollapsed }">
|
||||
|
||||
<!-- ── GAUCHE : Transparence IA ──────────────────────────────────────── -->
|
||||
<div class="bandeau-col">
|
||||
<p class="bandeau-label">Transparence IA</p>
|
||||
<template v-if="stats">
|
||||
<p class="bandeau-value">
|
||||
Coût IA ce mois : <strong>{{ stats.cout_mois_eur.toFixed(2) }} €</strong>
|
||||
·
|
||||
Tokens : <strong>{{ stats.tokens_mois.toLocaleString('fr-FR') }}</strong>
|
||||
</p>
|
||||
<!-- Jauge -->
|
||||
<div class="jauge-track" aria-label="Budget IA consommé" role="progressbar" :aria-valuenow="jaugePct" aria-valuemin="0" aria-valuemax="100">
|
||||
<div class="jauge-fill" :style="{ width: jaugePct + '%' }" />
|
||||
</div>
|
||||
<p class="bandeau-sub">
|
||||
{{ stats.requetes_mois }} requête{{ stats.requetes_mois !== 1 ? 's' : '' }} ce mois
|
||||
</p>
|
||||
</template>
|
||||
<template v-else-if="loading">
|
||||
<p class="bandeau-sub">Chargement…</p>
|
||||
</template>
|
||||
<template v-else>
|
||||
<p class="bandeau-sub">Données indisponibles</p>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- ── MILIEU : CTA Soutien ──────────────────────────────────────────── -->
|
||||
<div class="bandeau-col bandeau-col--center">
|
||||
<div class="soutenir-wrap">
|
||||
<button
|
||||
class="btn-soutenir"
|
||||
type="button"
|
||||
@click="modalOpen = true"
|
||||
@mouseenter="tooltipVisible = true"
|
||||
@mouseleave="tooltipVisible = false"
|
||||
@focus="tooltipVisible = true"
|
||||
@blur="tooltipVisible = false"
|
||||
aria-label="Soutenir le projet AEP sur Liberapay"
|
||||
>
|
||||
Soutenir le projet
|
||||
</button>
|
||||
<!-- Tooltip au hover -->
|
||||
<div v-if="tooltipVisible" class="soutenir-tooltip" role="tooltip">
|
||||
1 € = 30 fiches mises en ligne
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── DROITE : Compteurs semaine ────────────────────────────────────── -->
|
||||
<div class="bandeau-col bandeau-col--right">
|
||||
<p class="bandeau-label">Cette semaine</p>
|
||||
<template v-if="stats">
|
||||
<p class="bandeau-value">
|
||||
{{ stats.fiches_semaine }} fiche{{ stats.fiches_semaine !== 1 ? 's' : '' }} ajoutée{{ stats.fiches_semaine !== 1 ? 's' : '' }}
|
||||
</p>
|
||||
<p class="bandeau-sub">
|
||||
{{ stats.requetes_chatbot_semaine }} requête{{ stats.requetes_chatbot_semaine !== 1 ? 's' : '' }} chatbot
|
||||
</p>
|
||||
</template>
|
||||
<template v-else>
|
||||
<p class="bandeau-sub">—</p>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Barre fine (état collapsed) -->
|
||||
<div class="bandeau-thin" :class="{ 'bandeau-thin--visible': isCollapsed }">
|
||||
<span class="bandeau-thin-label">AEP · Transparence IA</span>
|
||||
</div>
|
||||
|
||||
<!-- ── MODAL Liberapay ───────────────────────────────────────────────── -->
|
||||
<Teleport to="body">
|
||||
<Transition name="backdrop">
|
||||
<div
|
||||
v-if="modalOpen"
|
||||
class="modal-backdrop"
|
||||
@click="modalOpen = false"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Transition>
|
||||
<Transition name="modal">
|
||||
<div
|
||||
v-if="modalOpen"
|
||||
class="modal-box"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Soutenir AEP sur Liberapay"
|
||||
>
|
||||
<button
|
||||
class="modal-close"
|
||||
type="button"
|
||||
@click="modalOpen = false"
|
||||
aria-label="Fermer"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" aria-hidden="true">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
<h2 class="modal-title">Soutenir AEP</h2>
|
||||
<p class="modal-desc">
|
||||
AEP est un outil libre, sans publicité, financé par les dons.
|
||||
1 € finance environ 30 fiches mises en ligne.
|
||||
</p>
|
||||
<div class="modal-widget">
|
||||
<iframe
|
||||
src="https://liberapay.com/trans-former.fr/widgets/button.html"
|
||||
width="95"
|
||||
height="22"
|
||||
style="border: 0;"
|
||||
title="Faire un don sur Liberapay"
|
||||
/>
|
||||
</div>
|
||||
<a
|
||||
href="https://liberapay.com/trans-former.fr/donate"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="modal-link"
|
||||
>
|
||||
Faire un don sur Liberapay →
|
||||
</a>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
|
||||
</footer>
|
||||
|
||||
<!-- Mobile (< 1024px) : pas de FAB — Soutenir est dans le menu hamburger -->
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface Stats {
|
||||
cout_mois_eur: number
|
||||
budget_mois: number
|
||||
tokens_mois: number
|
||||
co2_kg: number
|
||||
requetes_mois: number
|
||||
fiches_semaine: number
|
||||
requetes_chatbot_semaine: number
|
||||
}
|
||||
|
||||
const stats = ref<Stats | null>(null)
|
||||
const loading = ref(true)
|
||||
const modalOpen = ref(false)
|
||||
const tooltipVisible = ref(false)
|
||||
|
||||
// Desktop — replié par défaut, déploie au hover, replie immédiatement à la sortie
|
||||
const bandeauEl = ref<HTMLElement | null>(null)
|
||||
const isCollapsed = ref(true) // replié par défaut
|
||||
|
||||
const REFRESH_MS = 5 * 60 * 1000 // 5 minutes
|
||||
|
||||
// Détection mobile côté client
|
||||
const isMobile = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
isMobile.value = window.innerWidth < 1024
|
||||
|
||||
const handleResize = () => {
|
||||
isMobile.value = window.innerWidth < 1024
|
||||
}
|
||||
window.addEventListener('resize', handleResize)
|
||||
onUnmounted(() => window.removeEventListener('resize', handleResize))
|
||||
|
||||
fetchStats()
|
||||
const interval = setInterval(fetchStats, REFRESH_MS)
|
||||
onUnmounted(() => clearInterval(interval))
|
||||
})
|
||||
|
||||
function onMouseEnter() {
|
||||
isCollapsed.value = false
|
||||
}
|
||||
|
||||
function onMouseLeave() {
|
||||
// Repli immédiat — pas de timer
|
||||
isCollapsed.value = true
|
||||
}
|
||||
|
||||
async function fetchStats() {
|
||||
try {
|
||||
const res = await $fetch<Stats>('/api/stats')
|
||||
stats.value = res
|
||||
} catch {
|
||||
stats.value = null
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const jaugePct = computed(() => {
|
||||
if (!stats.value) return 0
|
||||
return Math.min(100, Math.round((stats.value.cout_mois_eur / stats.value.budget_mois) * 100))
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* ── Bandeau bas ─────────────────────────────────────────────────────────── */
|
||||
.bandeau-bas {
|
||||
background: rgba(26, 34, 56, 0.7); /* opacité 70% */
|
||||
color: var(--nav-text-on-primary);
|
||||
font-family: var(--nav-font, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif);
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||
transition: min-height 0.25s ease;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bandeau-collapsed {
|
||||
min-height: 32px !important;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.bandeau-inner {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 10px 20px;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
min-height: 64px;
|
||||
transition: opacity 0.2s ease, max-height 0.3s ease;
|
||||
max-height: 200px;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.bandeau-inner--hidden {
|
||||
opacity: 0;
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* Barre fine (collapsed) */
|
||||
.bandeau-thin {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.bandeau-thin--visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.bandeau-thin-label {
|
||||
font-size: 0.65rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* ── Colonnes ────────────────────────────────────────────────────────────── */
|
||||
.bandeau-col {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.bandeau-col--center {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 auto;
|
||||
padding-top: 8px; /* décaler légèrement vers le bas pour mieux centrer dans la hauteur */
|
||||
}
|
||||
|
||||
.bandeau-col--right {
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
/* ── Typo ────────────────────────────────────────────────────────────────── */
|
||||
.bandeau-label {
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.07em;
|
||||
opacity: 0.65;
|
||||
margin: 0;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.bandeau-value {
|
||||
font-size: 0.775rem;
|
||||
font-weight: 500;
|
||||
color: var(--nav-text-on-primary);
|
||||
margin: 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.bandeau-value strong {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.bandeau-sub {
|
||||
font-size: 0.68rem;
|
||||
opacity: 0.7;
|
||||
margin: 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* ── Jauge budget ────────────────────────────────────────────────────────── */
|
||||
.jauge-track {
|
||||
width: 100%;
|
||||
max-width: 180px;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
overflow: hidden;
|
||||
margin: 3px 0;
|
||||
}
|
||||
|
||||
.jauge-fill {
|
||||
height: 100%;
|
||||
border-radius: 2px;
|
||||
background: var(--nav-accent);
|
||||
transition: width 0.6s ease;
|
||||
}
|
||||
|
||||
/* ── Bouton soutenir + tooltip ───────────────────────────────────────────── */
|
||||
.soutenir-wrap {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.btn-soutenir {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 7px 16px;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
background: var(--nav-accent);
|
||||
color: var(--nav-text);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: opacity 0.15s, transform 0.1s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btn-soutenir:hover { opacity: 0.88; transform: translateY(-1px); }
|
||||
.btn-soutenir:active { opacity: 1; transform: translateY(0); }
|
||||
|
||||
.soutenir-tooltip {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 8px);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: var(--nav-primary-solid, #1a2238);
|
||||
color: var(--nav-text-on-primary);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
padding: 5px 10px;
|
||||
border-radius: 6px;
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.soutenir-tooltip::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
border: 5px solid transparent;
|
||||
border-top-color: var(--nav-primary-solid, #1a2238);
|
||||
}
|
||||
|
||||
/* ── Modal ───────────────────────────────────────────────────────────────── */
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 2000;
|
||||
background: rgba(26, 34, 56, 0.55);
|
||||
}
|
||||
|
||||
.modal-box {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
z-index: 2001;
|
||||
background: var(--nav-surface, #ffffff);
|
||||
border-radius: 16px;
|
||||
padding: 28px 24px 24px;
|
||||
width: min(380px, 90vw);
|
||||
box-shadow: 0 8px 40px rgba(26, 34, 56, 0.22);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
position: absolute;
|
||||
top: 14px;
|
||||
right: 14px;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
background: var(--nav-bg-alt, #eee9df);
|
||||
color: var(--nav-text-muted, rgba(26,34,56,0.55));
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.modal-close:hover { background: var(--nav-bg, #f8f6f1); }
|
||||
|
||||
.modal-title {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 700;
|
||||
color: var(--nav-text, #1a2238);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.modal-desc {
|
||||
font-size: 0.85rem;
|
||||
color: var(--nav-text-muted, rgba(26,34,56,0.55));
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.modal-widget {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
.modal-link {
|
||||
display: block;
|
||||
text-align: center;
|
||||
font-size: 0.8rem;
|
||||
color: var(--nav-primary-solid, #1a2238);
|
||||
text-decoration: underline;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.modal-link:hover { opacity: 0.7; }
|
||||
|
||||
/* ── Transitions ─────────────────────────────────────────────────────────── */
|
||||
.backdrop-enter-active, .backdrop-leave-active { transition: opacity 0.2s ease; }
|
||||
.backdrop-enter-from, .backdrop-leave-to { opacity: 0; }
|
||||
|
||||
.modal-enter-active, .modal-leave-active { transition: opacity 0.2s ease, transform 0.2s ease; }
|
||||
.modal-enter-from, .modal-leave-to { opacity: 0; transform: translate(-50%, -48%); }
|
||||
|
||||
.sheet-enter-active, .sheet-leave-active { transition: transform 0.3s cubic-bezier(0.32, 0.72, 0, 1); }
|
||||
.sheet-enter-from, .sheet-leave-to { transform: translateY(100%); }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.btn-soutenir { transition: none; }
|
||||
.jauge-fill { transition: none; }
|
||||
.modal-enter-active, .modal-leave-active { transition: none; }
|
||||
.backdrop-enter-active, .backdrop-leave-active { transition: none; }
|
||||
.sheet-enter-active, .sheet-leave-active { transition: none; }
|
||||
.bandeau-bas { transition: none; }
|
||||
.bandeau-inner { transition: none; }
|
||||
}
|
||||
</style>
|
||||
+58
-17
@@ -30,7 +30,7 @@ const LINKS_INFLUENCE = [
|
||||
{ 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 (toutes les ecoles progressistes vs cap-vert / ecofascismes)
|
||||
// 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' },
|
||||
@@ -107,7 +107,6 @@ async function initGraph() {
|
||||
})
|
||||
|
||||
// ---- SIMULATION D3 (auteurs) ----
|
||||
// Pre-positionner chaque auteur pres de son ecole + jitter aleatoire pour eviter le rush initial vers la droite
|
||||
const auteurNodes: any[] = props.data.auteurs.map(a => {
|
||||
const ecole = ecoleMap.get(a.ecole_principale)
|
||||
const jitter = () => (Math.random() - 0.5) * 80
|
||||
@@ -148,7 +147,6 @@ async function initGraph() {
|
||||
const allNodes = [...ecoleFixedNodes, ...auteurNodes]
|
||||
|
||||
if (simulation) simulation.stop()
|
||||
// Phase 8.D : sim ajustee pour 171 auteurs (vs 28 v2.1, densite 6x)
|
||||
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))
|
||||
@@ -170,7 +168,6 @@ async function initGraph() {
|
||||
}).strength(0.15))
|
||||
|
||||
// ---- NOEUDS ECOLES visibles (couche 3.5) ----
|
||||
// Cercles proportionnels au count d'auteurs, fixes aux centroids Bonpote, cliquables
|
||||
const gEcoles = g.append('g').attr('class', 'ecoles-nodes')
|
||||
ecoleFixedNodes.forEach(eNode => {
|
||||
const ecole = ecoleMap.get(eNode.ecoleId)
|
||||
@@ -194,6 +191,47 @@ async function initGraph() {
|
||||
})
|
||||
.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) ----
|
||||
@@ -202,7 +240,6 @@ async function initGraph() {
|
||||
.attr('stroke', 'rgba(150,150,150,0.28)').attr('stroke-width', 1.2)
|
||||
|
||||
// ---- EDGE LABELS - sous-courants (couche 4b) ----
|
||||
// Afficher label "decroissance" sur lien Servigne (sous-courant specifique - option C)
|
||||
const subcourantLinks = links.filter((l: any) => l.isSubcourant)
|
||||
d3EdgeLabelSel = gLinks.selectAll('text.pensees-edge-label')
|
||||
.data(subcourantLinks)
|
||||
@@ -223,7 +260,7 @@ async function initGraph() {
|
||||
emit('select-auteur', d.id)
|
||||
})
|
||||
|
||||
// Phase 8.D : grisage conditionnel auteurs non-ingeres (ingere:false)
|
||||
// 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')
|
||||
@@ -231,7 +268,7 @@ async function initGraph() {
|
||||
.attr('stroke-width', 1.5)
|
||||
.attr('opacity', (d: any) => d.ingere ? 1 : 0.35)
|
||||
|
||||
// ---- LABELS AUTEURS (couche 6 - fix 7.1 : drop-shadow blanc) ----
|
||||
// ---- LABELS AUTEURS (couche 6 - drop-shadow blanc) ----
|
||||
d3NodeSel.append('text')
|
||||
.attr('class', 'pensees-auteur-label')
|
||||
.text((d: any) => d.nom.split(' ').pop() ?? d.nom)
|
||||
@@ -268,7 +305,6 @@ async function initGraph() {
|
||||
.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)
|
||||
|
||||
// Edge labels positions (milieu du lien)
|
||||
d3EdgeLabelSel
|
||||
.attr('x', (d: any) => (d.source.x + d.target.x) / 2)
|
||||
.attr('y', (d: any) => (d.source.y + d.target.y) / 2)
|
||||
@@ -281,9 +317,20 @@ async function initGraph() {
|
||||
})
|
||||
}
|
||||
|
||||
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() } })
|
||||
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() {
|
||||
@@ -297,7 +344,6 @@ defineExpose({ triggerResize })
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* ---- Labels auteurs : fix 7.1 drop-shadow blanc pour lisibilite sur pastel ---- */
|
||||
.pensees-auteur-label {
|
||||
fill: #1a1a1a;
|
||||
font-weight: 600;
|
||||
@@ -306,7 +352,6 @@ defineExpose({ triggerResize })
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* ---- Labels edge sous-courants (option C : seulement les liens secondaires) ---- */
|
||||
.pensees-edge-label {
|
||||
fill: #555;
|
||||
font-size: 8.5px;
|
||||
@@ -318,14 +363,10 @@ defineExpose({ triggerResize })
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ---- Voronoi cellules : non-blurre Phase 8.F (revert Phase 8.D) ---- */
|
||||
/* Blur retire ; les cellules colorees Bonpote-aligned suffisent visuellement. */
|
||||
|
||||
.ecole-node {
|
||||
transition: opacity 0.15s, r 0.15s;
|
||||
}
|
||||
.ecole-node:hover {
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
+390
-388
@@ -1,446 +1,448 @@
|
||||
<template>
|
||||
<!-- Mode overlay : bouton flottant bottom-right (legacy) -->
|
||||
<template v-if="!inline">
|
||||
<button v-if="!open" @click="open = true"
|
||||
class="fixed bottom-6 right-6 z-[1000] flex items-center gap-2 px-4 rounded-full shadow-lg"
|
||||
style="height:48px;background:var(--nav-primary);color:var(--nav-text-on-primary);font-size:0.875rem;font-weight:600;"
|
||||
aria-label="Chatbot Pensees Ecologiques">
|
||||
<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>
|
||||
<span>Pensees ?</span>
|
||||
</button>
|
||||
|
||||
<Transition name="cpanel">
|
||||
<div v-if="open" class="fixed bottom-6 right-6 z-[1000] flex flex-col"
|
||||
style="width:min(360px,calc(100vw - 24px));max-height:60vh;background:var(--nav-surface);border-radius:14px;box-shadow:0 8px 32px rgba(26,34,56,0.22);overflow:hidden;border:1px solid var(--nav-bg-alt);"
|
||||
role="dialog" aria-modal="true" aria-label="RAG Pensees Ecologiques">
|
||||
|
||||
<!-- Header overlay -->
|
||||
<div class="flex items-center justify-between px-4 py-3 shrink-0" style="border-bottom:1px solid var(--nav-bg-alt);background:var(--nav-bg);">
|
||||
<div>
|
||||
<p class="text-sm font-bold" style="color:var(--nav-text);">RAG Pensees Ecologiques</p>
|
||||
</div>
|
||||
<button @click="open = false" class="flex items-center justify-center w-7 h-7 rounded-full hover:opacity-70"
|
||||
style="background:var(--nav-bg-alt);color:var(--nav-text-muted);" aria-label="Fermer">
|
||||
<svg width="12" height="12" 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 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>
|
||||
|
||||
<!-- Corpus toggle overlay -->
|
||||
<div class="shrink-0 px-3 pt-2 pb-1" style="background:var(--nav-bg);border-bottom:1px solid var(--nav-bg-alt);">
|
||||
<div class="flex gap-1" role="group" aria-label="Choisir le corpus">
|
||||
<button v-for="opt in corpusOptions" :key="opt.value" @click="setCorpus(opt.value)" :title="opt.tooltip"
|
||||
class="flex-1 px-2 py-1 rounded text-xs font-medium transition-colors"
|
||||
:style="corpus === opt.value ? 'background:var(--nav-primary);color:var(--nav-text-on-primary);' : 'background:var(--nav-bg-alt);color:var(--nav-text-muted);'"
|
||||
:aria-pressed="corpus === opt.value">{{ opt.label }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Messages overlay -->
|
||||
<div ref="msgElOverlay" class="flex-1 overflow-y-auto px-4 py-3 flex flex-col gap-3" style="min-height:0;">
|
||||
<div v-if="messages.length === 0" style="font-size:0.8rem;color:var(--nav-text-muted);line-height:1.5;">
|
||||
<template v-if="corpus === 'pensees'">Pose une question sur les pensees ecologiques...</template>
|
||||
<template v-else-if="corpus === 'projets'">Pose une question sur les projets d'architecture de Jules...</template>
|
||||
<template v-else>Pose une question sur les pensees ecologiques ancrees dans les projets archi de Jules.</template>
|
||||
</div>
|
||||
<template v-for="(msg, i) in messages" :key="i">
|
||||
<div v-if="msg.role === 'user'" class="self-end max-w-[85%] px-3 py-2 rounded-xl text-sm"
|
||||
style="background:var(--nav-primary);color:var(--nav-text-on-primary);font-weight:500;">{{ msg.content }}</div>
|
||||
<div v-else class="self-start max-w-full">
|
||||
<div class="px-3 py-2 rounded-xl text-sm leading-relaxed" style="background:var(--nav-bg-alt);color:var(--nav-text);"
|
||||
v-html="renderMd(stripSrc(msg.content))" />
|
||||
<div v-if="filteredSources(msg.content).length" class="mt-1.5">
|
||||
<button @click="toggled[i] = !toggled[i]" class="flex items-center gap-1 text-xs hover:opacity-70" style="color:var(--nav-text-muted);">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"
|
||||
:style="`transform:rotate(${toggled[i] ? 90 : 0}deg);transition:transform 0.15s`"><polyline points="9 18 15 12 9 6"/></svg>
|
||||
Sources ({{ filteredSources(msg.content).length }})
|
||||
</button>
|
||||
<div v-if="toggled[i]" class="mt-1 flex flex-col gap-1">
|
||||
<div v-for="(s, si) in filteredSources(msg.content)" :key="si" class="px-2 py-1 rounded text-xs"
|
||||
style="background:var(--nav-bg-alt);color:var(--nav-text-muted);border-left:2px solid var(--nav-primary-solid);">
|
||||
<span style="font-weight:600;color:var(--nav-text);">[{{ si + 1 }}]</span> {{ s }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div v-if="loading" class="self-start px-3 py-2 rounded-xl" style="background:var(--nav-bg-alt);">
|
||||
<span class="dots"><span/><span style="animation-delay:150ms"/><span style="animation-delay:300ms"/></span>
|
||||
</div>
|
||||
<div v-if="err" class="text-xs px-3 py-2 rounded-xl" style="background:#fee;color:#c0392b;">{{ err }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Input overlay -->
|
||||
<div class="shrink-0 px-3 py-3" style="border-top:1px solid var(--nav-bg-alt);">
|
||||
<div class="flex items-center gap-2" style="position:relative;">
|
||||
<!-- Hashtag autocomplete dropdown (Slack/Discord pattern, au-dessus de l'input) -->
|
||||
<div v-if="hashtagDropdownOpen && hashtagSuggestions.length"
|
||||
class="hashtag-dropdown"
|
||||
style="position:absolute;bottom:100%;left:0;right:0;margin-bottom:6px;max-height:220px;overflow-y:auto;background:var(--nav-surface);border:1px solid var(--nav-bg-alt);border-radius:8px;box-shadow:0 -4px 12px rgba(0,0,0,0.12);z-index:50;">
|
||||
<div v-for="(auteur, idx) in hashtagSuggestions" :key="auteur.id"
|
||||
@mousedown.prevent="applyHashtagSuggestion(auteur)"
|
||||
@mouseenter="hashtagSelectedIndex = idx"
|
||||
class="px-3 py-2 cursor-pointer text-sm"
|
||||
:style="idx === hashtagSelectedIndex ? 'background:var(--nav-primary);color:var(--nav-text-on-primary);' : 'color:var(--nav-text);'">
|
||||
<span style="font-weight:600;">#{{ auteur.id }}</span>
|
||||
<span :style="idx === hashtagSelectedIndex ? 'opacity:0.85;margin-left:8px;font-size:0.78rem;' : 'opacity:0.65;margin-left:8px;font-size:0.78rem;color:var(--nav-text-muted);'">{{ auteur.nom }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<input ref="inputElOverlay" v-model="q" type="text" placeholder="Ta question, tape #auteur pour cibler" maxlength="500"
|
||||
class="flex-1 px-3 py-2 rounded-lg text-sm outline-none"
|
||||
style="background:var(--nav-bg);color:var(--nav-text);border:1px solid var(--nav-bg-alt);"
|
||||
@keydown="onInputKeydown" />
|
||||
<button @click="send" :disabled="loading || !q.trim()"
|
||||
class="flex items-center justify-center w-9 h-9 rounded-lg"
|
||||
:style="loading||!q.trim() ? 'background:var(--nav-bg-alt);opacity:0.5;cursor:not-allowed;' : 'background:var(--nav-primary);cursor:pointer;'"
|
||||
aria-label="Envoyer">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="color:white;">
|
||||
<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>
|
||||
|
||||
<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>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<!-- Mode inline : remplit 100% de son parent slot -->
|
||||
<div v-else
|
||||
class="flex flex-col w-full h-full"
|
||||
style="background:var(--nav-surface);overflow:hidden;"
|
||||
role="region" aria-label="RAG Pensees Ecologiques">
|
||||
|
||||
<!-- Header inline -->
|
||||
<div class="flex items-center justify-between px-4 py-2 shrink-0" style="border-bottom:1px solid var(--nav-bg-alt);background:var(--nav-bg);">
|
||||
<div>
|
||||
<p class="text-sm font-bold" style="color:var(--nav-text);">RAG Pensees Ecologiques</p>
|
||||
<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>
|
||||
|
||||
<!-- Corpus toggle inline -->
|
||||
<div class="shrink-0 px-3 pt-2 pb-1" style="background:var(--nav-bg);border-bottom:1px solid var(--nav-bg-alt);">
|
||||
<div class="flex gap-1" role="group" aria-label="Choisir le corpus">
|
||||
<button v-for="opt in corpusOptions" :key="opt.value" @click="setCorpus(opt.value)" :title="opt.tooltip"
|
||||
class="flex-1 px-2 py-1 rounded text-xs font-medium transition-colors"
|
||||
:style="corpus === opt.value ? 'background:var(--nav-primary);color:var(--nav-text-on-primary);' : 'background:var(--nav-bg-alt);color:var(--nav-text-muted);'"
|
||||
:aria-pressed="corpus === opt.value">{{ opt.label }}</button>
|
||||
<!-- 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>
|
||||
</div>
|
||||
|
||||
<!-- Messages inline -->
|
||||
<div ref="msgElInline" class="flex-1 overflow-y-auto px-4 py-3 flex flex-col gap-3" style="min-height:0;">
|
||||
<div v-if="messages.length === 0" style="font-size:0.8rem;color:var(--nav-text-muted);line-height:1.5;">
|
||||
<template v-if="corpus === 'pensees'">Pose une question sur les pensees ecologiques : ecosocialisme, decroissance, ecofeminismes, technocritique, deep ecology...</template>
|
||||
<template v-else-if="corpus === 'projets'">Pose une question sur les projets d'architecture de Jules : Butte Pinson, strategie thermique, partis pris constructifs...</template>
|
||||
<template v-else>Pose une question sur les pensees ecologiques ancrees dans les projets archi de Jules (corpus croise, defaut).</template>
|
||||
</div>
|
||||
<!-- Messages -->
|
||||
<template v-for="(msg, i) in messages" :key="i">
|
||||
<div v-if="msg.role === 'user'" class="self-end max-w-[85%] px-3 py-2 rounded-xl text-sm"
|
||||
style="background:var(--nav-primary);color:var(--nav-text-on-primary);font-weight:500;">{{ msg.content }}</div>
|
||||
<div v-else class="self-start max-w-full">
|
||||
<div class="px-3 py-2 rounded-xl text-sm leading-relaxed" style="background:var(--nav-bg-alt);color:var(--nav-text);"
|
||||
v-html="renderMd(stripSrc(msg.content))" />
|
||||
<div v-if="filteredSources(msg.content).length" class="mt-1.5">
|
||||
<button @click="toggled[i] = !toggled[i]" class="flex items-center gap-1 text-xs hover:opacity-70" style="color:var(--nav-text-muted);">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"
|
||||
:style="`transform:rotate(${toggled[i] ? 90 : 0}deg);transition:transform 0.15s`"><polyline points="9 18 15 12 9 6"/></svg>
|
||||
Sources ({{ filteredSources(msg.content).length }})
|
||||
</button>
|
||||
<div v-if="toggled[i]" class="mt-1 flex flex-col gap-1">
|
||||
<div v-for="(s, si) in filteredSources(msg.content)" :key="si" class="px-2 py-1 rounded text-xs"
|
||||
style="background:var(--nav-bg-alt);color:var(--nav-text-muted);border-left:2px solid var(--nav-primary-solid);">
|
||||
<span style="font-weight:600;color:var(--nav-text);">[{{ si + 1 }}]</span> {{ s }}
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
<div v-if="loading" class="self-start px-3 py-2 rounded-xl" style="background:var(--nav-bg-alt);">
|
||||
<span class="dots"><span/><span style="animation-delay:150ms"/><span style="animation-delay:300ms"/></span>
|
||||
|
||||
<!-- Chargement -->
|
||||
<div v-if="loading" class="chatbot-bubble assistant loading">
|
||||
<span class="dot" /><span class="dot" /><span class="dot" />
|
||||
</div>
|
||||
<div v-if="err" class="text-xs px-3 py-2 rounded-xl" style="background:#fee;color:#c0392b;">{{ err }}</div>
|
||||
|
||||
<!-- Erreur -->
|
||||
<div v-if="errorMsg" class="chatbot-error">{{ errorMsg }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Input inline -->
|
||||
<div class="shrink-0 px-3 py-3" style="border-top:1px solid var(--nav-bg-alt);">
|
||||
<div class="flex items-center gap-2" style="position:relative;">
|
||||
<!-- Hashtag autocomplete dropdown (Slack/Discord pattern, au-dessus de l'input) -->
|
||||
<div v-if="hashtagDropdownOpen && hashtagSuggestions.length"
|
||||
class="hashtag-dropdown"
|
||||
style="position:absolute;bottom:100%;left:0;right:0;margin-bottom:6px;max-height:220px;overflow-y:auto;background:var(--nav-surface);border:1px solid var(--nav-bg-alt);border-radius:8px;box-shadow:0 -4px 12px rgba(0,0,0,0.12);z-index:50;">
|
||||
<div v-for="(auteur, idx) in hashtagSuggestions" :key="auteur.id"
|
||||
@mousedown.prevent="applyHashtagSuggestion(auteur)"
|
||||
@mouseenter="hashtagSelectedIndex = idx"
|
||||
class="px-3 py-2 cursor-pointer text-sm"
|
||||
:style="idx === hashtagSelectedIndex ? 'background:var(--nav-primary);color:var(--nav-text-on-primary);' : 'color:var(--nav-text);'">
|
||||
<span style="font-weight:600;">#{{ auteur.id }}</span>
|
||||
<span :style="idx === hashtagSelectedIndex ? 'opacity:0.85;margin-left:8px;font-size:0.78rem;' : 'opacity:0.65;margin-left:8px;font-size:0.78rem;color:var(--nav-text-muted);'">{{ auteur.nom }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<input ref="inputElInline" v-model="q" type="text" placeholder="Ta question, tape #auteur pour cibler" maxlength="500"
|
||||
class="flex-1 px-3 py-2 rounded-lg text-sm outline-none"
|
||||
style="background:var(--nav-bg);color:var(--nav-text);border:1px solid var(--nav-bg-alt);"
|
||||
@keydown="onInputKeydown" />
|
||||
<button @click="send" :disabled="loading || !q.trim()"
|
||||
class="flex items-center justify-center w-9 h-9 rounded-lg"
|
||||
:style="loading||!q.trim() ? 'background:var(--nav-bg-alt);opacity:0.5;cursor:not-allowed;' : 'background:var(--nav-primary);cursor:pointer;'"
|
||||
aria-label="Envoyer">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="color:white;">
|
||||
<line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/>
|
||||
</svg>
|
||||
</button>
|
||||
</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">
|
||||
interface Message { role: 'user' | 'assistant'; content: string }
|
||||
interface AuteurMini { id: string; nom: string }
|
||||
import { useMarkdown } from '~/composables/useMarkdown'
|
||||
|
||||
type CorpusMode = 'pensees' | 'projets' | 'both'
|
||||
const { render: renderMd } = useMarkdown()
|
||||
|
||||
const CORPUS_STORAGE_KEY = 'chatbot-pensees-corpus'
|
||||
|
||||
const PROJECT_SOURCE_PATTERNS = [/butte.?pinson/i, /butte_pinson/i]
|
||||
|
||||
function isProjectSource(s: string): boolean {
|
||||
return PROJECT_SOURCE_PATTERNS.some(p => p.test(s))
|
||||
interface ChatMessage {
|
||||
role: 'user' | 'assistant'
|
||||
content: string
|
||||
auteur?: { slug: string; nom: string } | null
|
||||
}
|
||||
|
||||
const corpusOptions: { value: CorpusMode; label: string; tooltip: string }[] = [
|
||||
{ value: 'pensees', label: 'Pensees', tooltip: 'Corpus FRACAS uniquement (auteurs ecologie politique)' },
|
||||
{ value: 'projets', label: 'Projets', tooltip: 'Projets archi de Jules uniquement' },
|
||||
{ value: 'both', label: 'Croise*', tooltip: 'Projets ancres + pensees en eclairage (defaut)' },
|
||||
]
|
||||
|
||||
const props = defineProps<{
|
||||
auteurContext?: string | null
|
||||
auteurContext: string | null
|
||||
inline?: boolean
|
||||
}>()
|
||||
|
||||
const open = ref(false)
|
||||
const q = ref('')
|
||||
const messages = ref<Message[]>([])
|
||||
const messages = ref<ChatMessage[]>([])
|
||||
const inputText = ref('')
|
||||
const loading = ref(false)
|
||||
const err = ref('')
|
||||
const toggled = ref<Record<number, boolean>>({})
|
||||
const msgElOverlay = ref<HTMLElement | null>(null)
|
||||
const msgElInline = ref<HTMLElement | null>(null)
|
||||
const inputElOverlay = ref<HTMLInputElement | null>(null)
|
||||
const inputElInline = ref<HTMLInputElement | null>(null)
|
||||
const corpusCount = 18
|
||||
const errorMsg = ref('')
|
||||
const corpusMode = ref<'pensees' | 'projets' | 'both'>('pensees')
|
||||
const messagesContainer = ref<HTMLElement | null>(null)
|
||||
|
||||
const corpus = ref<CorpusMode>('pensees')
|
||||
|
||||
// Phase 8.E : hashtag mentions
|
||||
const auteursIngeres = ref<AuteurMini[]>([])
|
||||
const hashtagSuggestions = ref<AuteurMini[]>([])
|
||||
const hashtagDropdownOpen = ref(false)
|
||||
const hashtagSelectedIndex = ref(0)
|
||||
|
||||
function getActiveInput(): HTMLInputElement | null {
|
||||
return props.inline ? inputElInline.value : inputElOverlay.value
|
||||
}
|
||||
|
||||
function detectHashtagAtCursor(input: string, cursorPos: number): { start: number; partial: string } | null {
|
||||
const before = input.slice(0, cursorPos)
|
||||
const m = before.match(/#([a-z0-9-]*)$/i)
|
||||
if (!m) return null
|
||||
return { start: m.index ?? 0, partial: (m[1] || '').toLowerCase() }
|
||||
}
|
||||
|
||||
function updateHashtagSuggestions() {
|
||||
const el = getActiveInput()
|
||||
const cursorPos = el?.selectionStart ?? q.value.length
|
||||
const detection = detectHashtagAtCursor(q.value, cursorPos)
|
||||
// Ouvrir dès que le # est présent (partial vide accepté pour afficher la liste)
|
||||
if (!detection) {
|
||||
hashtagDropdownOpen.value = false
|
||||
return
|
||||
}
|
||||
const partial = detection.partial
|
||||
const list = partial.length === 0
|
||||
? auteursIngeres.value.slice(0, 8)
|
||||
: auteursIngeres.value
|
||||
.filter(a => a.id.toLowerCase().includes(partial) || a.nom.toLowerCase().includes(partial))
|
||||
.slice(0, 8)
|
||||
hashtagSuggestions.value = list
|
||||
hashtagDropdownOpen.value = list.length > 0
|
||||
hashtagSelectedIndex.value = 0
|
||||
}
|
||||
|
||||
function applyHashtagSuggestion(auteur: AuteurMini) {
|
||||
const el = getActiveInput()
|
||||
const cursorPos = el?.selectionStart ?? q.value.length
|
||||
const detection = detectHashtagAtCursor(q.value, cursorPos)
|
||||
if (!detection) return
|
||||
const before = q.value.slice(0, detection.start)
|
||||
const after = q.value.slice(cursorPos)
|
||||
const insert = '#' + auteur.id + ' '
|
||||
q.value = before + insert + after
|
||||
hashtagDropdownOpen.value = false
|
||||
nextTick(() => {
|
||||
const focusEl = getActiveInput()
|
||||
if (!focusEl) return
|
||||
focusEl.focus()
|
||||
const newPos = before.length + insert.length
|
||||
focusEl.setSelectionRange(newPos, newPos)
|
||||
})
|
||||
}
|
||||
|
||||
function onInputKeydown(e: KeyboardEvent) {
|
||||
if (hashtagDropdownOpen.value && hashtagSuggestions.value.length > 0) {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
hashtagSelectedIndex.value = (hashtagSelectedIndex.value + 1) % hashtagSuggestions.value.length
|
||||
return
|
||||
}
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
hashtagSelectedIndex.value = (hashtagSelectedIndex.value - 1 + hashtagSuggestions.value.length) % hashtagSuggestions.value.length
|
||||
return
|
||||
}
|
||||
if (e.key === 'Enter' || e.key === 'Tab') {
|
||||
e.preventDefault()
|
||||
applyHashtagSuggestion(hashtagSuggestions.value[hashtagSelectedIndex.value])
|
||||
return
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
hashtagDropdownOpen.value = false
|
||||
return
|
||||
}
|
||||
}
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
send()
|
||||
}
|
||||
}
|
||||
|
||||
watch(q, () => {
|
||||
updateHashtagSuggestions()
|
||||
watch(() => props.auteurContext, () => {
|
||||
messages.value = []
|
||||
errorMsg.value = ''
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
const saved = window.localStorage.getItem(CORPUS_STORAGE_KEY) as CorpusMode | null
|
||||
if (saved && ['pensees', 'projets', 'both'].includes(saved)) {
|
||||
corpus.value = saved
|
||||
}
|
||||
// Chargement liste auteurs ingérés pour autocomplete hashtag
|
||||
try {
|
||||
const data = await $fetch<any>('/data/auteurs-pensees.json')
|
||||
auteursIngeres.value = (data?.auteurs ?? [])
|
||||
.filter((a: any) => a.ingere === true)
|
||||
.map((a: any) => ({ id: String(a.id), nom: String(a.nom) }))
|
||||
} catch (e) {
|
||||
console.error('Erreur chargement auteurs-pensees.json pour hashtag', e)
|
||||
}
|
||||
})
|
||||
|
||||
function setCorpus(val: CorpusMode) {
|
||||
corpus.value = val
|
||||
window.localStorage.setItem(CORPUS_STORAGE_KEY, val)
|
||||
function clearMessages() {
|
||||
messages.value = []
|
||||
errorMsg.value = ''
|
||||
}
|
||||
|
||||
watch(open, (val) => {
|
||||
if (!val) return
|
||||
nextTick(() => inputElOverlay.value?.focus())
|
||||
if (props.auteurContext && messages.value.length === 0)
|
||||
q.value = `Quelles sont les theses centrales de ${props.auteurContext} ?`
|
||||
})
|
||||
watch(() => props.auteurContext, (ctx) => {
|
||||
if (!ctx) return
|
||||
if (!props.inline && !open.value) open.value = true
|
||||
if (messages.value.length === 0) q.value = `Quelles sont les theses centrales de ${ctx} ?`
|
||||
})
|
||||
function scrollToBottom() {
|
||||
if (messagesContainer.value) {
|
||||
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
|
||||
}
|
||||
}
|
||||
|
||||
async function send() {
|
||||
const query = q.value.trim()
|
||||
async function sendMessage() {
|
||||
const query = inputText.value.trim()
|
||||
if (!query || loading.value) return
|
||||
|
||||
// Extraire le premier hashtag matchant un auteur ingéré
|
||||
let auteurSlug: string | null = null
|
||||
const matches = [...query.matchAll(/#([a-z0-9-]+)/gi)]
|
||||
for (const m of matches) {
|
||||
const slug = m[1].toLowerCase()
|
||||
if (auteursIngeres.value.find(a => a.id === slug)) {
|
||||
auteurSlug = slug
|
||||
break
|
||||
}
|
||||
}
|
||||
// Premier hashtag non-matché (pour info utilisateur si jamais ne match aucun)
|
||||
let auteurSlugUnmatched: string | null = null
|
||||
if (!auteurSlug && matches.length > 0) {
|
||||
auteurSlugUnmatched = matches[0][1].toLowerCase()
|
||||
}
|
||||
|
||||
err.value = ''
|
||||
inputText.value = ''
|
||||
errorMsg.value = ''
|
||||
messages.value.push({ role: 'user', content: query })
|
||||
q.value = ''
|
||||
hashtagDropdownOpen.value = false
|
||||
loading.value = true
|
||||
|
||||
await nextTick()
|
||||
scrollBottom()
|
||||
scrollToBottom()
|
||||
|
||||
try {
|
||||
const res = await $fetch<any>('/api/chatbot-pensees', {
|
||||
const res = await $fetch<{
|
||||
response: string
|
||||
auteur: { slug: string; nom: string } | null
|
||||
}>('/api/chatbot-pensees', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
query,
|
||||
mode: 'hybrid',
|
||||
corpus: corpus.value,
|
||||
auteur_slug: auteurSlug ?? auteurSlugUnmatched,
|
||||
corpus: corpusMode.value,
|
||||
auteur_slug: props.auteurContext ? slugify(props.auteurContext) : undefined,
|
||||
},
|
||||
})
|
||||
let responseText = res.response ?? ''
|
||||
if (res.auteur_unmatched) {
|
||||
responseText = `*(Aucun livre de #${res.auteur_unmatched} n'est ingéré dans le RAG. Je réponds depuis la carte entière.)*\n\n` + responseText
|
||||
}
|
||||
messages.value.push({ role: 'assistant', content: responseText })
|
||||
messages.value.push({
|
||||
role: 'assistant',
|
||||
content: res.response,
|
||||
auteur: res.auteur ?? null,
|
||||
})
|
||||
} catch (e: any) {
|
||||
const s = e?.response?.status ?? e?.statusCode
|
||||
err.value = s === 429 ? 'Limite atteinte.' : s === 503 ? 'RAG indisponible.' : 'Erreur, reessaie.'
|
||||
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()
|
||||
scrollBottom()
|
||||
scrollToBottom()
|
||||
}
|
||||
}
|
||||
|
||||
function scrollBottom() {
|
||||
const el = props.inline ? msgElInline.value : msgElOverlay.value
|
||||
if (el) el.scrollTop = el.scrollHeight
|
||||
}
|
||||
|
||||
function renderMd(t: string) {
|
||||
return '<p>' + t.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>').replace(/\*(.+?)\*/g, '<em>$1</em>').replace(/\n\n/g, '</p><p>').replace(/\n/g, '<br>') + '</p>'
|
||||
}
|
||||
function stripSrc(t: string) { return t.replace(/\n*(?:Sources?|References?)\s*:[\s\S]*$/i, '').trim() }
|
||||
|
||||
function parseSrc(t: string): string[] {
|
||||
const bloc = t.match(/\n*(?:Sources?|References?)\s*:\n?([\s\S]+?)$/i)
|
||||
if (bloc) return bloc[1].split('\n').map(l => l.replace(/^[-*\d.[\]]+\s*/, '').trim()).filter(l => l.length > 3)
|
||||
return [...new Set([...t.matchAll(/\[([^\]]{5,80})\]/g)].filter(m => m[1].includes(' - ')).map(m => m[1]))]
|
||||
}
|
||||
|
||||
function filteredSources(t: string): string[] {
|
||||
const all = parseSrc(t)
|
||||
if (corpus.value === 'both') return all
|
||||
if (corpus.value === 'projets') return all.filter(s => isProjectSource(s))
|
||||
return all.filter(s => !isProjectSource(s))
|
||||
function slugify(str: string): string {
|
||||
return str.toLowerCase()
|
||||
.normalize('NFD').replace(/[̀-ͯ]/g, '')
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.cpanel-enter-active { transition: opacity 0.2s, transform 0.22s cubic-bezier(0.34,1.56,0.64,1); }
|
||||
.cpanel-leave-active { transition: opacity 0.18s, transform 0.15s ease-in; }
|
||||
.cpanel-enter-from { opacity: 0; transform: translateY(12px) scale(0.95); }
|
||||
.cpanel-leave-to { opacity: 0; transform: translateY(8px) scale(0.97); }
|
||||
.dots span { display:inline-block;width:6px;height:6px;border-radius:50%;background:var(--nav-text-muted);margin:0 2px;animation:bounce 1s infinite; }
|
||||
@keyframes bounce { 0%,80%,100%{transform:translateY(0)}40%{transform:translateY(-5px)} }
|
||||
.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,10 +52,8 @@
|
||||
<div class="chatbot-body-inner" ref="messagesContainer">
|
||||
<!-- Onboarding -->
|
||||
<div v-if="messages.length === 0" class="onboarding-bubble">
|
||||
<p>Je connais les structures d'entraide pour architectes référencées sur cette carte — appui juridique, technique, économique, formation, santé mentale, gestion d'agence…</p>
|
||||
<p>Décris ta situation, je te propose les fiches les plus pertinentes.</p>
|
||||
<p class="example">Exemple : "Architecte salarié, litige avec mon employeur, besoin d'un appui juridique droit du travail, Île-de-France."</p>
|
||||
<p style="margin-top: 8px; font-size: 0.72rem; opacity: 0.6;">Propulsé par Mistral FR — serveur européen souverain, zéro rétention.</p>
|
||||
<p>Explore les 120 structures de la carte par la conversation. Je peux t'aider à trouver des collectifs, agences ou réseaux selon ta situation, ta pratique ou tes inspirations du moment.</p>
|
||||
<p class="example">Exemple : "Je cherche des acteurs de la rénovation de maisons individuelles en France, plutôt en milieu rural, avec des approches biosourcées ou low-tech."</p>
|
||||
</div>
|
||||
|
||||
<!-- Messages -->
|
||||
|
||||
@@ -69,7 +69,6 @@
|
||||
<div ref="messagesContainer" class="flex-1 overflow-y-auto px-4 py-4 flex flex-col gap-3">
|
||||
<!-- Message onboarding (avant la première question) -->
|
||||
<div v-if="messages.length === 0" class="onboarding-bubble">
|
||||
<p>Ce chatbot fonctionne sur un serveur européen souverain (Mistral FR, zéro rétention), conçu sobre en énergie.</p>
|
||||
<p>Pour m'aider à te répondre efficacement, formule ta requête ainsi :</p>
|
||||
<ul>
|
||||
<li>• Besoin : [ce que tu cherches]</li>
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
<template>
|
||||
<component
|
||||
:is="as"
|
||||
:type="as === 'button' ? 'button' : undefined"
|
||||
class="chip"
|
||||
:class="[`chip--${variant}`, { 'chip--active': active }]"
|
||||
:aria-pressed="as === 'button' ? active : undefined"
|
||||
@click="as === 'button' && emit('toggle')"
|
||||
>
|
||||
<span class="chip__label">{{ label }}</span>
|
||||
<span v-if="count !== undefined" class="chip__count">{{ count }}</span>
|
||||
<button
|
||||
v-if="removable"
|
||||
type="button"
|
||||
class="chip__remove"
|
||||
:aria-label="`Retirer ${label}`"
|
||||
@click.stop="emit('remove')"
|
||||
>
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
</component>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
label: string
|
||||
variant?: 'echelle' | 'fonction' | 'posture'
|
||||
active?: boolean
|
||||
count?: number
|
||||
as?: 'button' | 'span'
|
||||
removable?: boolean
|
||||
}>(),
|
||||
{
|
||||
variant: 'fonction',
|
||||
active: false,
|
||||
count: undefined,
|
||||
as: 'button',
|
||||
removable: false,
|
||||
}
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
toggle: []
|
||||
remove: []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2xs);
|
||||
padding: var(--space-2xs) var(--space-sm);
|
||||
border-radius: var(--radius-full);
|
||||
font-size: var(--fs-xs);
|
||||
line-height: var(--lh-tight);
|
||||
font-weight: var(--fw-medium);
|
||||
border: 1.5px solid transparent;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
min-height: 32px;
|
||||
box-sizing: border-box;
|
||||
background: none;
|
||||
transition: background-color var(--dur-fast) var(--ease-out),
|
||||
color var(--dur-fast) var(--ease-out),
|
||||
border-color var(--dur-fast) var(--ease-out),
|
||||
transform var(--dur-fast) var(--ease-out);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.chip {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
.chip:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.chip:focus-visible {
|
||||
outline: 2px solid var(--focus-ring);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* ── Échelle : outline bleu, fond transparent, actif = plein ────────────── */
|
||||
.chip--echelle {
|
||||
border-color: var(--primary);
|
||||
color: var(--text);
|
||||
background: transparent;
|
||||
}
|
||||
.chip--echelle:hover {
|
||||
background: var(--primary-hover);
|
||||
border-color: var(--primary-hover);
|
||||
color: var(--on-primary);
|
||||
}
|
||||
.chip--echelle.chip--active {
|
||||
background: var(--primary);
|
||||
border-color: var(--primary);
|
||||
color: var(--on-primary);
|
||||
}
|
||||
.chip--echelle.chip--active:hover {
|
||||
background: var(--primary-hover);
|
||||
border-color: var(--primary-hover);
|
||||
}
|
||||
|
||||
/* ── Fonction/hashtag : pleine bleu, inactif = bg-alt ────────────────────── */
|
||||
.chip--fonction {
|
||||
border-color: transparent;
|
||||
background: var(--bg-alt);
|
||||
color: var(--text);
|
||||
}
|
||||
.chip--fonction:hover {
|
||||
background: var(--primary-hover);
|
||||
color: var(--on-primary);
|
||||
}
|
||||
.chip--fonction.chip--active {
|
||||
background: var(--primary);
|
||||
color: var(--on-primary);
|
||||
}
|
||||
.chip--fonction.chip--active:hover {
|
||||
background: var(--primary-hover);
|
||||
}
|
||||
|
||||
/* ── Posture (préparée pour plus tard) : outline safran ──────────────────── */
|
||||
.chip--posture {
|
||||
border-color: var(--accent);
|
||||
color: var(--text);
|
||||
background: transparent;
|
||||
}
|
||||
.chip--posture:hover {
|
||||
background: var(--accent);
|
||||
color: var(--on-accent);
|
||||
}
|
||||
.chip--posture.chip--active {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: var(--on-accent);
|
||||
}
|
||||
|
||||
.chip__count {
|
||||
font-size: 0.85em;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.chip__remove {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
opacity: 0.7;
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
.chip__remove:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
.chip__remove:focus-visible {
|
||||
outline: 2px solid var(--focus-ring);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,19 @@
|
||||
<template>
|
||||
<div class="chip-group" role="group" :aria-label="ariaLabel">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
ariaLabel: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.chip-group {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2xs) var(--space-xs);
|
||||
}
|
||||
</style>
|
||||
@@ -1,17 +1,16 @@
|
||||
<template>
|
||||
<div class="space-y-1">
|
||||
<p class="filter-label">ÉCHELLE</p>
|
||||
<div class="chips-row">
|
||||
<span
|
||||
<div class="space-y-1.5">
|
||||
<p class="text-xs font-bold uppercase tracking-widest" style="color: var(--nav-text-muted);">Échelle</p>
|
||||
<ChipGroup aria-label="Filtrer par échelle">
|
||||
<Chip
|
||||
v-for="option in ECHELLES"
|
||||
:key="option"
|
||||
class="chip"
|
||||
:style="isSelected(option)
|
||||
? '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(option)"
|
||||
>{{ option }}</span>
|
||||
</div>
|
||||
:label="option"
|
||||
variant="echelle"
|
||||
:active="isSelected(option)"
|
||||
@toggle="toggle(option)"
|
||||
/>
|
||||
</ChipGroup>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -39,24 +38,3 @@ function toggle(option: string) {
|
||||
}
|
||||
}
|
||||
</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;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.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>
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
<template>
|
||||
<section class="echelle-tete" :class="{ 'echelle-tete--compact': compact }" :aria-label="`Échelle ${e.id} : ${e.titre}`">
|
||||
<p class="echelle-tete__kicker">
|
||||
<span class="echelle-tete__badge" aria-hidden="true">{{ e.id }}</span>
|
||||
<span class="echelle-tete__titre">{{ e.titre }}<span v-if="e.sousTitre" class="echelle-tete__sous-titre">{{ ' ' + e.sousTitre }}</span></span>
|
||||
</p>
|
||||
<ul class="echelle-tete__mots">
|
||||
<li v-for="m in e.mots" :key="m">{{ m }}</li>
|
||||
</ul>
|
||||
<p class="echelle-tete__phrase">{{ e.phrase }}</p>
|
||||
<slot />
|
||||
<p v-if="contribuer" class="echelle-tete__contribuer">
|
||||
<span>{{ ECHELLES_CADRE_COURT }}</span>
|
||||
<NuxtLink :to="`/proposer?categorie=${e.categorieProposer}`" @click="$emit('navigate')">Proposer une ressource →</NuxtLink>
|
||||
</p>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ECHELLES, type EchelleId } from '~/utils/echelles'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
echelle: EchelleId
|
||||
/** Variante resserrée, pour une popup ou un en-tête centré. */
|
||||
compact?: boolean
|
||||
/** Affiche la ligne « on pose l'infrastructure, le collectif nourrit ». */
|
||||
contribuer?: boolean
|
||||
}>(), { compact: false, contribuer: true })
|
||||
|
||||
defineEmits<{ navigate: [] }>()
|
||||
|
||||
const e = computed(() => ECHELLES[props.echelle])
|
||||
const ECHELLES_CADRE_COURT = 'Chaque section est ouverte aux contributions.'
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.echelle-tete {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
margin: 0 0 var(--space-md);
|
||||
}
|
||||
|
||||
.echelle-tete__kicker {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--space-sm);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.echelle-tete__badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 2rem;
|
||||
padding: 0.1rem 0.5rem;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--accent);
|
||||
color: var(--on-accent);
|
||||
font-weight: var(--fw-bold);
|
||||
font-size: var(--fs-sm);
|
||||
letter-spacing: 0.04em;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.echelle-tete__titre {
|
||||
font-size: var(--fs-lg);
|
||||
font-weight: var(--fw-bold);
|
||||
color: var(--text);
|
||||
line-height: var(--lh-tight);
|
||||
}
|
||||
|
||||
.echelle-tete__sous-titre {
|
||||
font-weight: var(--fw-regular);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.echelle-tete__mots {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.15rem 0;
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--text);
|
||||
line-height: var(--lh-base);
|
||||
}
|
||||
.echelle-tete__mots li:not(:last-child)::after {
|
||||
content: '·';
|
||||
margin: 0 0.5rem;
|
||||
color: var(--accent);
|
||||
font-weight: var(--fw-bold);
|
||||
}
|
||||
|
||||
.echelle-tete__phrase {
|
||||
margin: 0;
|
||||
font-size: var(--fs-sm);
|
||||
font-style: italic;
|
||||
color: var(--text-muted);
|
||||
line-height: var(--lh-base);
|
||||
}
|
||||
|
||||
.echelle-tete__contribuer {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem 0.75rem;
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.echelle-tete__contribuer a {
|
||||
color: var(--link);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
.echelle-tete__contribuer a:hover { color: var(--link-hover); }
|
||||
|
||||
.echelle-tete--compact { text-align: left; }
|
||||
.echelle-tete--compact .echelle-tete__titre { font-size: var(--fs-base); }
|
||||
</style>
|
||||
@@ -1,98 +0,0 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="backdrop">
|
||||
<div v-if="open && auteur" class="fixed inset-0 z-[1500]" style="background: rgba(26,34,56,0.55);" @click="emit('close')" aria-hidden="true" />
|
||||
</Transition>
|
||||
<Transition name="modal">
|
||||
<div v-if="open && auteur" class="fixed z-[1501] left-1/2 flex flex-col"
|
||||
style="top:50%;transform:translate(-50%,-50%);width:min(520px,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">
|
||||
<!-- Header -->
|
||||
<div class="flex items-start justify-between px-5 py-4 shrink-0"
|
||||
:style="`border-bottom: 3px solid ${ecoleColor}; background: var(--nav-surface);`">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<span class="px-2 py-0.5 rounded-full text-xs font-semibold" :style="`background:${ecoleColor}22;color:${ecoleColor};`">{{ ecoleLabel }}</span>
|
||||
<span v-for="eid in auteur.ecoles.filter(e => e !== auteur.ecole_principale)" :key="eid"
|
||||
class="px-2 py-0.5 rounded-full text-xs" :style="`background:${getEcoleColor(eid)}22;color:${getEcoleColor(eid)};`">{{ getEcoleLabel(eid) }}</span>
|
||||
</div>
|
||||
<h2 class="mt-2 font-bold text-lg leading-tight" style="color:var(--nav-text);">{{ auteur.nom }}</h2>
|
||||
<p class="text-sm" style="color:var(--nav-text-muted);">{{ auteur.dates }}</p>
|
||||
</div>
|
||||
<button @click="emit('close')" 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>
|
||||
<!-- Body -->
|
||||
<div class="flex-1 overflow-y-auto px-5 py-4 flex flex-col gap-4">
|
||||
<p class="text-sm leading-relaxed" style="color:var(--nav-text);">{{ auteur.bio_courte }}</p>
|
||||
<div v-if="auteur.theses_cles.length">
|
||||
<p class="text-xs font-bold uppercase tracking-widest mb-2" style="color:var(--nav-text-muted);">Theses cles</p>
|
||||
<ul class="flex flex-col gap-1.5">
|
||||
<li v-for="t in auteur.theses_cles" :key="t" class="flex items-start gap-2 text-sm" style="color:var(--nav-text);">
|
||||
<span class="mt-1.5 w-1.5 h-1.5 rounded-full shrink-0" :style="`background:${ecoleColor};`"></span>
|
||||
<span>{{ t }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div v-if="auteur.livres_rag.length">
|
||||
<p class="text-xs font-bold uppercase tracking-widest mb-2" style="color:var(--nav-text-muted);">Livres dans le RAG</p>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div v-for="l in auteur.livres_rag" :key="l.slug" class="flex items-start gap-3 p-3 rounded-lg" style="background:var(--nav-bg-alt);">
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-semibold leading-snug" style="color:var(--nav-text);">{{ l.titre }}</p>
|
||||
<p class="text-xs mt-0.5" style="color:var(--nav-text-muted);">{{ l.annee }}</p>
|
||||
</div>
|
||||
<div class="flex gap-1 shrink-0">
|
||||
<span v-for="c in l.couches" :key="c" class="px-1.5 py-0.5 rounded text-xs" style="background:var(--nav-surface);color:var(--nav-text-muted);">{{ c }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Footer -->
|
||||
<div class="shrink-0 px-5 py-3 border-t" style="border-color:var(--nav-bg-alt);">
|
||||
<button @click="emit('interroger-rag', auteurId!)" class="w-full py-2.5 rounded-lg text-sm font-semibold hover:opacity-80"
|
||||
:style="`background:${ecoleColor};color:white;`">
|
||||
Interroger le RAG sur {{ auteur.nom.split(' ').pop() }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
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 EcoleData { id: string; label: string; color: string }
|
||||
interface PenseesData { ecoles: EcoleData[]; auteurs: AuteurData[] }
|
||||
|
||||
const props = defineProps<{ open: boolean; auteurId: string | null; data: PenseesData | null }>()
|
||||
const emit = defineEmits<{ close: []; 'interroger-rag': [auteurId: string] }>()
|
||||
|
||||
const auteur = computed<AuteurData | null>(() => {
|
||||
if (!props.auteurId || !props.data) return null
|
||||
return props.data.auteurs.find(a => a.id === props.auteurId) ?? null
|
||||
})
|
||||
const ecoleColor = computed(() => props.data?.ecoles.find(e => e.id === auteur.value?.ecole_principale)?.color ?? '#888')
|
||||
const ecoleLabel = computed(() => props.data?.ecoles.find(e => e.id === auteur.value?.ecole_principale)?.label ?? '')
|
||||
function getEcoleColor(id: string) { return props.data?.ecoles.find(e => e.id === id)?.color ?? '#888' }
|
||||
function getEcoleLabel(id: string) { return props.data?.ecoles.find(e => e.id === id)?.label ?? id }
|
||||
|
||||
function onKey(e: KeyboardEvent) { if (e.key === 'Escape' && props.open) emit('close') }
|
||||
onMounted(() => window.addEventListener('keydown', onKey))
|
||||
onUnmounted(() => window.removeEventListener('keydown', onKey))
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.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); }
|
||||
</style>
|
||||
@@ -183,6 +183,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const baseTiles = useBaseTiles()
|
||||
import type { Org } from '~/types/org'
|
||||
|
||||
const props = defineProps<{ org: Org }>()
|
||||
@@ -283,7 +284,7 @@ onMounted(async () => {
|
||||
attributionControl: false,
|
||||
})
|
||||
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
L.tileLayer(baseTiles.url(document.documentElement.classList.contains('dark')), {
|
||||
maxZoom: 18,
|
||||
}).addTo(map)
|
||||
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="backdrop">
|
||||
<div v-if="open && ecole" class="fixed inset-0 z-[1500]" style="background: rgba(26,34,56,0.55);" @click="emit('close')" aria-hidden="true" />
|
||||
</Transition>
|
||||
<Transition name="modal">
|
||||
<div v-if="open && ecole" class="fixed z-[1501] left-1/2 flex flex-col"
|
||||
style="top:50%;transform:translate(-50%,-50%);width:min(540px,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">
|
||||
<!-- Header -->
|
||||
<div class="flex items-start justify-between px-5 py-4 shrink-0"
|
||||
:style="`border-bottom: 3px solid ${ecole.color}; background: var(--nav-surface);`">
|
||||
<div class="flex-1 min-w-0">
|
||||
<span class="px-2 py-0.5 rounded-full text-xs font-semibold" :style="`background:${ecole.color}22;color:${ecole.color};`">Ecole de pensee</span>
|
||||
<h2 class="mt-2 font-bold text-lg leading-tight" style="color:var(--nav-text);">{{ ecole.label }}</h2>
|
||||
<p class="text-sm mt-1 leading-relaxed" style="color:var(--nav-text-muted);">{{ ecole.description }}</p>
|
||||
</div>
|
||||
<button @click="emit('close')" 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>
|
||||
<!-- Body -->
|
||||
<div class="flex-1 overflow-y-auto px-5 py-4 flex flex-col gap-4">
|
||||
<div v-if="auteursIngeres.length">
|
||||
<p class="text-xs font-bold uppercase tracking-widest mb-2" style="color:var(--nav-text-muted);">Dans le RAG ({{ auteursIngeres.length }})</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button v-for="a in auteursIngeres" :key="a.id"
|
||||
class="px-2.5 py-1 rounded-full text-xs font-medium hover:opacity-80 transition-opacity"
|
||||
:style="`background:${ecole.color}22;color:${ecole.color};border:1px solid ${ecole.color}44;cursor:pointer;`"
|
||||
@click="onSelectAuteur(a.id)">
|
||||
{{ a.nom }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="auteursNonIngeres.length">
|
||||
<p class="text-xs font-bold uppercase tracking-widest mb-2" style="color:var(--nav-text-muted);">Presents dans Bonpote, pas encore dans le RAG ({{ auteursNonIngeres.length }})</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<span v-for="a in auteursNonIngeres" :key="a.id"
|
||||
class="px-2.5 py-1 rounded-full text-xs"
|
||||
style="background:var(--nav-bg-alt);color:var(--nav-text-muted);">
|
||||
{{ a.nom }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Footer -->
|
||||
<div class="shrink-0 px-5 py-3 border-t" style="border-color:var(--nav-bg-alt);">
|
||||
<button @click="emit('interroger-ecole', ecoleId!)" class="w-full py-2.5 rounded-lg text-sm font-semibold hover:opacity-80"
|
||||
:style="`background:${ecole.color};color:white;`">
|
||||
Interroger le RAG sur {{ ecole.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface AuteurData { id: string; nom: string; ecoles: string[]; ecole_principale: string; ingere: boolean }
|
||||
interface EcoleData { id: string; label: string; description: string; color: string }
|
||||
interface PenseesData { ecoles: EcoleData[]; auteurs: AuteurData[] }
|
||||
|
||||
const props = defineProps<{ open: boolean; ecoleId: string | null; data: PenseesData | null }>()
|
||||
const emit = defineEmits<{ close: []; 'select-auteur': [auteurId: string]; 'interroger-ecole': [ecoleId: string] }>()
|
||||
|
||||
const ecole = computed<EcoleData | null>(() => {
|
||||
if (!props.ecoleId || !props.data) return null
|
||||
return props.data.ecoles.find(e => e.id === props.ecoleId) ?? null
|
||||
})
|
||||
|
||||
const auteursIngeres = computed(() => {
|
||||
if (!props.ecoleId || !props.data) return []
|
||||
return props.data.auteurs.filter(a => a.ecole_principale === props.ecoleId && (a as any).ingere)
|
||||
})
|
||||
|
||||
const auteursNonIngeres = computed(() => {
|
||||
if (!props.ecoleId || !props.data) return []
|
||||
return props.data.auteurs.filter(a => a.ecole_principale === props.ecoleId && !(a as any).ingere)
|
||||
})
|
||||
|
||||
function onSelectAuteur(id: string) {
|
||||
emit('close')
|
||||
emit('select-auteur', id)
|
||||
}
|
||||
|
||||
function onKey(e: KeyboardEvent) { if (e.key === 'Escape' && props.open) emit('close') }
|
||||
onMounted(() => window.addEventListener('keydown', onKey))
|
||||
onUnmounted(() => window.removeEventListener('keydown', onKey))
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.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); }
|
||||
</style>
|
||||
@@ -0,0 +1,103 @@
|
||||
<template>
|
||||
<button
|
||||
type="button"
|
||||
class="fiche-mini-card"
|
||||
:aria-label="fiche.nom"
|
||||
@click="emit('open')"
|
||||
>
|
||||
<span class="fiche-mini-card__nom">{{ fiche.nom }}</span>
|
||||
<span v-if="fiche.resume" class="fiche-mini-card__resume">{{ fiche.resume }}</span>
|
||||
|
||||
<ChipGroup
|
||||
v-if="fiche.chips.length || fiche.echelle"
|
||||
:aria-label="`Étiquettes de ${fiche.nom}`"
|
||||
class="fiche-mini-card__chips"
|
||||
>
|
||||
<Chip v-if="fiche.echelle" as="span" variant="echelle" :label="fiche.echelle" />
|
||||
<Chip v-for="chip in fiche.chips" :key="chip" as="span" variant="fonction" :label="chip" />
|
||||
</ChipGroup>
|
||||
|
||||
<span v-if="!fiche.geoloc" class="fiche-mini-card__no-geo">Sans localisation</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { MiniFiche } from '~/composables/useFicheAdapter'
|
||||
|
||||
defineProps<{
|
||||
fiche: MiniFiche
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
open: []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fiche-mini-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
width: 100%;
|
||||
padding: var(--space-sm);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
box-sizing: border-box;
|
||||
transition: background-color var(--dur-fast) var(--ease-out),
|
||||
box-shadow var(--dur-fast) var(--ease-out);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.fiche-mini-card {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
.fiche-mini-card:hover {
|
||||
background: var(--surface-2);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.fiche-mini-card:focus-visible {
|
||||
outline: 2px solid var(--focus-ring);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.fiche-mini-card__nom {
|
||||
width: 100%;
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: var(--fw-semibold);
|
||||
line-height: var(--lh-tight);
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.fiche-mini-card__resume {
|
||||
width: 100%;
|
||||
margin-top: var(--space-2xs);
|
||||
font-size: var(--fs-xs);
|
||||
line-height: var(--lh-base);
|
||||
color: var(--text-muted);
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.fiche-mini-card__chips {
|
||||
margin-top: var(--space-xs);
|
||||
}
|
||||
|
||||
.fiche-mini-card__no-geo {
|
||||
margin-top: var(--space-2xs);
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--text-subtle);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,168 @@
|
||||
<!--
|
||||
FichesPanel — B3-M3
|
||||
Panneau « toutes les fiches » : reçoit une liste déjà filtrée (recherche +
|
||||
filtres en cours côté page), l'affiche en grille de FicheMiniCard, et
|
||||
expose ses propres chips de filtrage branchées sur le MÊME état que la
|
||||
page appelante (pas un second état — voir vérification du brief).
|
||||
Ne crée pas de second champ de recherche : `query` sert uniquement à
|
||||
nuancer le message d'état vide.
|
||||
-->
|
||||
<template>
|
||||
<div class="fiches-panel">
|
||||
<div class="fiches-panel__head">
|
||||
<p class="fiches-panel__intention">{{ intention }}</p>
|
||||
|
||||
<div class="fiches-panel__count">
|
||||
{{ fiches.length }} fiche{{ fiches.length > 1 ? 's' : '' }}
|
||||
</div>
|
||||
|
||||
<ChipGroup v-if="hashtags.length" aria-label="Filtrer les fiches" class="fiches-panel__chips">
|
||||
<Chip
|
||||
v-for="tag in visibleHashtags"
|
||||
:key="tag"
|
||||
:label="tag"
|
||||
variant="fonction"
|
||||
:active="selected.includes(tag)"
|
||||
@toggle="toggle(tag)"
|
||||
/>
|
||||
<Chip
|
||||
v-if="isCollapsible"
|
||||
:label="expanded ? 'Réduire' : `Voir les ${hiddenCount} autres`"
|
||||
variant="echelle"
|
||||
:active="expanded"
|
||||
@toggle="expanded = !expanded"
|
||||
/>
|
||||
</ChipGroup>
|
||||
</div>
|
||||
|
||||
<div class="fiches-panel__body">
|
||||
<p v-if="fiches.length === 0" class="fiches-panel__empty">
|
||||
Aucun résultat<template v-if="query"> pour « {{ query }} »</template>.
|
||||
</p>
|
||||
|
||||
<div v-else class="fiches-panel__grid">
|
||||
<FicheMiniCard
|
||||
v-for="fiche in fiches"
|
||||
:key="`${fiche.kind}-${fiche.id}`"
|
||||
:fiche="fiche"
|
||||
@open="emit('open', fiche)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { MiniFiche } from '~/composables/useFicheAdapter'
|
||||
|
||||
const props = defineProps<{
|
||||
fiches: MiniFiche[]
|
||||
hashtags: string[]
|
||||
selected: string[]
|
||||
query: string
|
||||
intention: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:selected': [value: string[]]
|
||||
open: [fiche: MiniFiche]
|
||||
}>()
|
||||
|
||||
// Hashtags repliés (B3-M4) — au-delà de COLLAPSE_THRESHOLD chips, n'affiche
|
||||
// que les premières + un Chip-bouton « voir les N autres ». Les chips déjà
|
||||
// actives restent toujours visibles même repliées. `/` (10 fonctions) ne
|
||||
// dépasse jamais le seuil ; `/agences` (~60 hashtags) se replie. État
|
||||
// déplié non persistant (ref locale, reset à chaque montage du composant).
|
||||
const COLLAPSE_THRESHOLD = 14
|
||||
const expanded = ref(false)
|
||||
|
||||
const isCollapsible = computed(() => props.hashtags.length > COLLAPSE_THRESHOLD)
|
||||
|
||||
const visibleHashtags = computed(() => {
|
||||
if (expanded.value || !isCollapsible.value) return props.hashtags
|
||||
const head = props.hashtags.slice(0, COLLAPSE_THRESHOLD)
|
||||
const headSet = new Set(head)
|
||||
const activeOutsideHead = props.hashtags.filter(
|
||||
(tag) => props.selected.includes(tag) && !headSet.has(tag)
|
||||
)
|
||||
return [...head, ...activeOutsideHead]
|
||||
})
|
||||
|
||||
const hiddenCount = computed(() => props.hashtags.length - visibleHashtags.value.length)
|
||||
|
||||
function toggle(tag: string) {
|
||||
if (props.selected.includes(tag)) {
|
||||
emit('update:selected', props.selected.filter((t) => t !== tag))
|
||||
} else {
|
||||
emit('update:selected', [...props.selected, tag])
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fiches-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.fiches-panel__head {
|
||||
flex-shrink: 0;
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.fiches-panel__intention {
|
||||
margin: 0 0 var(--space-xs);
|
||||
font-size: var(--fs-sm);
|
||||
line-height: var(--lh-base);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.fiches-panel__count {
|
||||
margin: 0 0 var(--space-xs);
|
||||
font-size: var(--fs-xs);
|
||||
font-weight: var(--fw-semibold);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--text-subtle);
|
||||
}
|
||||
|
||||
.fiches-panel__chips {
|
||||
margin-top: var(--space-2xs);
|
||||
}
|
||||
|
||||
.fiches-panel__body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: var(--space-md);
|
||||
}
|
||||
|
||||
.fiches-panel__empty {
|
||||
margin: var(--space-xl) 0;
|
||||
text-align: center;
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.fiches-panel__grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.fiches-panel__grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1280px) {
|
||||
.fiches-panel__grid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,33 +1,20 @@
|
||||
<template>
|
||||
<div class="space-y-1">
|
||||
<!-- Label + toggle collapse -->
|
||||
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 4px;">
|
||||
<p class="filter-label" style="margin-bottom: 0;">
|
||||
FONCTION
|
||||
<span v-if="modelValue.length" style="font-weight: 400; text-transform: none; letter-spacing: 0; font-size: 0.65rem; margin-left: 4px;">({{ modelValue.length }} active{{ modelValue.length > 1 ? 's' : '' }})</span>
|
||||
</p>
|
||||
<button
|
||||
@click="toggleCollapse"
|
||||
style="font-size: 0.7rem; color: var(--nav-text-muted); background: none; border: none; cursor: pointer; text-decoration: underline; padding: 0; white-space: nowrap;"
|
||||
>{{ isOpen ? 'Replier' : 'Fonctions (' + FONCTIONS.length + ')' }}</button>
|
||||
</div>
|
||||
|
||||
<!-- Chips (visible si ouvert ou si des fonctions sont actives) -->
|
||||
<div v-if="isOpen" class="chips-row">
|
||||
<span
|
||||
<div class="space-y-1.5">
|
||||
<p class="text-xs font-bold uppercase tracking-widest" style="color: var(--nav-text-muted);">Fonction</p>
|
||||
<ChipGroup aria-label="Filtrer par fonction">
|
||||
<Chip
|
||||
v-for="fn in FONCTIONS"
|
||||
:key="fn"
|
||||
class="chip"
|
||||
:style="modelValue.includes(fn)
|
||||
? 'background: var(--nav-primary); color: var(--nav-text-on-primary); font-weight: 600;'
|
||||
: 'background: var(--nav-bg-alt); color: var(--nav-text-muted);'"
|
||||
@click="toggle(fn)"
|
||||
>{{ fn }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Effacer (visible même replié si filtres actifs) -->
|
||||
:label="fn"
|
||||
variant="fonction"
|
||||
:active="modelValue.includes(fn)"
|
||||
:count="modelValue.includes(fn) ? (modelValue.indexOf(fn) + 1) : (counts[fn] ?? 0)"
|
||||
@toggle="toggle(fn)"
|
||||
/>
|
||||
</ChipGroup>
|
||||
<p v-if="modelValue.length" class="text-xs pt-0.5" style="color: var(--nav-text-muted);">
|
||||
<button @click="emit('update:modelValue', [])" class="underline hover:opacity-70">Effacer</button>
|
||||
{{ modelValue.length }} actif{{ modelValue.length > 1 ? 's' : '' }}
|
||||
<button @click="emit('update:modelValue', [])" class="ml-2 underline hover:opacity-70">Effacer</button>
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -55,25 +42,6 @@ const emit = defineEmits<{
|
||||
'update:modelValue': [value: string[]]
|
||||
}>()
|
||||
|
||||
// Replié par défaut, ouvre automatiquement quand des filtres sont actifs
|
||||
const manuallyOpen = ref(false)
|
||||
|
||||
const isOpen = computed(() => {
|
||||
return manuallyOpen.value || props.modelValue.length > 0
|
||||
})
|
||||
|
||||
function toggleCollapse() {
|
||||
// Si des filtres actifs forcent l'ouverture, on doit gérer le cas « forcer fermer »
|
||||
if (isOpen.value) {
|
||||
manuallyOpen.value = false
|
||||
// Si des fonctions sont actives, le computed va les réouvrir — on les efface
|
||||
// Non : on laisse le choix à l'utilisateur. On toggle juste manuallyOpen.
|
||||
// Quand replié avec filtres actifs, l'indicateur "(N actives)" reste visible.
|
||||
} else {
|
||||
manuallyOpen.value = true
|
||||
}
|
||||
}
|
||||
|
||||
function toggle(fn: string) {
|
||||
if (props.modelValue.includes(fn)) {
|
||||
emit('update:modelValue', props.modelValue.filter(f => f !== fn))
|
||||
@@ -82,23 +50,3 @@ function toggle(fn: string) {
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.filter-label {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--nav-text-muted);
|
||||
display: block;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.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>
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
<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>
|
||||
@@ -0,0 +1,134 @@
|
||||
<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>
|
||||
@@ -0,0 +1,337 @@
|
||||
<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 + contraste chip actif ──
|
||||
Mesuré avant modif (script de capture, af4-avant-*) : le chip actif de
|
||||
Chip.vue (variant fonction, fond rgba(26,34,56,.6) sur fond de page
|
||||
#f8f6f1) rend ≈ 4.16:1 au repos — sous le seuil AA 4.5:1. C'est un défaut
|
||||
général de Chip.vue (touche aussi les filtres de / et /agences), signalé
|
||||
hors mandat — voir rapport. On neutralise ce point pour CE formulaire
|
||||
seulement, sans toucher Chip.vue : fond plein (--nav-primary-solid) au
|
||||
lieu de la version translucide, pairing déjà utilisé ailleurs sur le site
|
||||
(pages/trouver-du-taf.vue .taff-tab--active). Mesuré après : ≈14.6:1. */
|
||||
.field-group :deep(.chip--fonction.chip--active) {
|
||||
background: var(--nav-primary-solid);
|
||||
color: var(--nav-text-on-primary);
|
||||
}
|
||||
|
||||
/* 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;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
|
||||
/* Survol collant sur tactile (le :hover de Chip.vue n'est pas gardé par
|
||||
@media (hover: hover)) : neutralisé ici pour les deux chips actifs de ce
|
||||
formulaire, sans toucher Chip.vue. Mesuré : le survol n'aggravait pas le
|
||||
contraste du principal (6.68:1 contre 4.16:1 au repos, avant modif) mais
|
||||
figeait un fond et un transform incohérents après un tap tant que rien ne
|
||||
les levait. */
|
||||
@media (hover: none) {
|
||||
.field-group :deep(.chip--fonction.chip--active) {
|
||||
transform: none !important;
|
||||
}
|
||||
.field-group :deep(.chip--fonction.chip--active:hover) {
|
||||
background: var(--nav-primary-solid) !important;
|
||||
color: var(--nav-text-on-primary) !important;
|
||||
transform: none !important;
|
||||
}
|
||||
.field-group :deep(.chip--secondaire.chip--active:hover) {
|
||||
background: transparent !important;
|
||||
border-color: var(--nav-primary-solid) !important;
|
||||
color: var(--nav-text) !important;
|
||||
transform: none !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,134 @@
|
||||
<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>
|
||||
@@ -0,0 +1,142 @@
|
||||
<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>
|
||||
@@ -0,0 +1,158 @@
|
||||
<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>
|
||||
@@ -877,7 +877,6 @@ onUnmounted(() => {
|
||||
/* Labels des structures dans le graphe (D3 injecte les <text>, donc style global) */
|
||||
.graph-view .graph-struct-label {
|
||||
fill: var(--nav-text);
|
||||
opacity: 0.7;
|
||||
paint-order: stroke;
|
||||
stroke: var(--nav-bg);
|
||||
stroke-width: 3px;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="hashtag-filter" style="padding: 8px 12px; background: var(--nav-surface);">
|
||||
<!-- Filtres famille -->
|
||||
<!-- Filtres famille — couleurs propres par famille (hors grammaire dimension Chip, non touché B3-M1) -->
|
||||
<div style="margin-bottom: 6px;">
|
||||
<span class="filter-label">FAMILLES</span>
|
||||
<div class="chips-row">
|
||||
@@ -25,17 +25,16 @@
|
||||
style="font-size: 0.7rem; color: var(--nav-text-muted); background: none; border: none; cursor: pointer; text-decoration: underline; padding: 0;"
|
||||
>{{ hashtagsVisible ? 'Replier' : 'Afficher (' + props.allHashtags.length + ')' }}</button>
|
||||
</div>
|
||||
<div v-if="hashtagsVisible" class="chips-row">
|
||||
<span
|
||||
<ChipGroup v-if="hashtagsVisible" aria-label="Filtrer par hashtag">
|
||||
<Chip
|
||||
v-for="tag in props.allHashtags"
|
||||
:key="tag"
|
||||
class="chip chip-small"
|
||||
:style="selectedHashtags.includes(tag)
|
||||
? 'background: var(--nav-primary); color: var(--nav-text-on-primary); font-weight: 600;'
|
||||
: 'background: var(--nav-bg-alt); color: var(--nav-text-muted);'"
|
||||
@click="toggleHashtag(tag)"
|
||||
>{{ tag }}</span>
|
||||
</div>
|
||||
:label="tag"
|
||||
variant="fonction"
|
||||
:active="selectedHashtags.includes(tag)"
|
||||
@toggle="toggleHashtag(tag)"
|
||||
/>
|
||||
</ChipGroup>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -93,5 +92,4 @@ function toggleHashtag(tag: string) {
|
||||
transition: all 0.15s;
|
||||
user-select: none;
|
||||
}
|
||||
.chip-small { font-size: 0.7rem; padding: 2px 8px; }
|
||||
</style>
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
<p style="font-size: 0.875rem; line-height: 1.6; opacity: 0.75; margin: 0 0 24px 0;">
|
||||
Elle ne prétend pas à l'exhaustivité. Elle est un geste politique :
|
||||
rendre visible ce qui se transforme, comment, par qui, où.
|
||||
5 familles et des hashtags vous permettent d'explorer.
|
||||
Six familles et des hashtags vous permettent d'explorer.
|
||||
</p>
|
||||
<button
|
||||
@click="dismiss"
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
<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>
|
||||
@@ -0,0 +1,67 @@
|
||||
<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>
|
||||
@@ -0,0 +1,651 @@
|
||||
<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>
|
||||
@@ -32,6 +32,7 @@
|
||||
|
||||
<div class="mission-body">
|
||||
<h2 id="mission-title" class="mission-title">{{ title }}</h2>
|
||||
<EchelleTete v-if="echelle" :echelle="echelle" compact @navigate="close" />
|
||||
<slot>
|
||||
<p class="mission-text">
|
||||
L'architecture est l'une des professions les plus complexes qui soit ; elle croise droit, technique, esthétique, économie, social, écologie — tout à la fois, tout simultanément, souvent sans filet. Paradoxalement, c'est aussi l'une des moins structurées sur le plan de l'entraide : peu de transmission horizontale, beaucoup d'isolement, une culture du chacun-pour-soi héritée d'une formation qui prépare à la compétition plus qu'à la coopération. On sort de l'école seul·e. On s'installe seul·e. On réinvente ce que d'autres ont déjà traversé.
|
||||
@@ -52,11 +53,15 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { EchelleId } from '~/utils/echelles'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
modelValue: boolean
|
||||
title?: string
|
||||
ctaLabel?: string
|
||||
storageKey?: string
|
||||
/** Tête d'échelle du deck (S / M / XL) affichée sous le titre. Absente sur /agences tant que l'arbitrage n° 1 n'est pas rendu. */
|
||||
echelle?: EchelleId
|
||||
}>(), {
|
||||
title: "L'écosystème d'entraide architecte",
|
||||
ctaLabel: 'Explorer la carte',
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const baseTiles = useBaseTiles()
|
||||
import type { Map, Marker, DivIcon } from 'leaflet'
|
||||
|
||||
interface Org {
|
||||
@@ -90,12 +91,10 @@ async function initMap() {
|
||||
|
||||
// Fond de carte CartoDB Positron (light ou dark selon theme)
|
||||
const isDark = typeof document !== 'undefined' && document.documentElement.classList.contains('dark')
|
||||
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'
|
||||
const tileUrl = baseTiles.url(isDark)
|
||||
|
||||
tileLayerInstance = L.tileLayer(tileUrl, {
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors © <a href="https://carto.com/attributions">CARTO</a>',
|
||||
attribution: baseTiles.attribution,
|
||||
maxZoom: 19,
|
||||
})
|
||||
tileLayerInstance.addTo(mapInstance!)
|
||||
@@ -212,9 +211,7 @@ function updateTileTheme(dark: boolean) {
|
||||
if (!mapInstance || !tileLayerInstance) return
|
||||
const L = (window as any).L
|
||||
if (!L) return
|
||||
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'
|
||||
const url = baseTiles.url(dark)
|
||||
tileLayerInstance.setUrl(url)
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const baseTiles = useBaseTiles()
|
||||
import type { Map, Marker, DivIcon } from 'leaflet'
|
||||
import type { StructureV2 } from '~/types/structure-v2'
|
||||
|
||||
@@ -87,12 +88,10 @@ async function initMap() {
|
||||
})
|
||||
|
||||
const isDark = typeof document !== 'undefined' && document.documentElement.classList.contains('dark')
|
||||
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'
|
||||
const tileUrl = baseTiles.url(isDark)
|
||||
|
||||
tileLayerInstance = L.tileLayer(tileUrl, {
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors © <a href="https://carto.com/attributions">CARTO</a>',
|
||||
attribution: baseTiles.attribution,
|
||||
maxZoom: 19,
|
||||
})
|
||||
tileLayerInstance.addTo(mapInstance!)
|
||||
@@ -213,9 +212,7 @@ function updateTileTheme(dark: boolean) {
|
||||
if (!mapInstance || !tileLayerInstance) return
|
||||
const L = (window as any).L
|
||||
if (!L) return
|
||||
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'
|
||||
const url = baseTiles.url(dark)
|
||||
tileLayerInstance.setUrl(url)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
<template>
|
||||
<NuxtLink
|
||||
:to="`/fiche/${org.Id}`"
|
||||
class="block bg-white rounded-xl shadow-sm border border-warm-200 hover:shadow-md hover:border-sage-300 transition-all duration-200 p-5"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3 mb-2">
|
||||
<h2 class="font-semibold text-gray-900 text-base leading-snug">{{ org.nom }}</h2>
|
||||
<TypeBadge v-if="org.type_org" :type="org.type_org" class="shrink-0 mt-0.5" />
|
||||
</div>
|
||||
|
||||
<p class="text-gray-600 text-sm leading-relaxed mb-3 line-clamp-2">
|
||||
{{ org.description }}
|
||||
</p>
|
||||
|
||||
<div v-if="tags.length" class="flex flex-wrap gap-1.5">
|
||||
<TagBadge
|
||||
v-for="tag in tags"
|
||||
:key="tag"
|
||||
:tag="tag"
|
||||
@click="$emit('filter-tag', tag)"
|
||||
/>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
org: {
|
||||
Id: number
|
||||
nom: string
|
||||
type_org?: string
|
||||
description?: string
|
||||
tags?: string
|
||||
lien?: string
|
||||
}
|
||||
}>()
|
||||
|
||||
defineEmits<{ 'filter-tag': [tag: string] }>()
|
||||
|
||||
const tags = computed(() =>
|
||||
props.org.tags
|
||||
? props.org.tags.split(',').map((t) => t.trim()).filter(Boolean)
|
||||
: []
|
||||
)
|
||||
</script>
|
||||
@@ -0,0 +1,150 @@
|
||||
<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,6 +38,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const baseTiles = useBaseTiles()
|
||||
import type { Map as LeafletMap, TileLayer } from 'leaflet'
|
||||
|
||||
interface Org {
|
||||
@@ -111,9 +112,7 @@ function createPinIcon(L: any, isPrioritaire: boolean, isSelected = false) {
|
||||
}
|
||||
|
||||
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'
|
||||
return baseTiles.url(dark)
|
||||
}
|
||||
|
||||
async function initSingleMap(domName: string) {
|
||||
@@ -132,7 +131,7 @@ async function initSingleMap(domName: string) {
|
||||
touchZoom: true, keyboard: false,
|
||||
})
|
||||
const tileLayer = L.tileLayer(getTileUrl(isDark), {
|
||||
attribution: '© OpenStreetMap contributors © CARTO', maxZoom: 19,
|
||||
attribution: baseTiles.attribution, maxZoom: 19,
|
||||
})
|
||||
tileLayer.addTo(map)
|
||||
tileLayers[domName] = tileLayer as unknown as TileLayer
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
<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>
|
||||
@@ -1,11 +0,0 @@
|
||||
<template>
|
||||
<span
|
||||
class="inline-block px-2 py-0.5 rounded-full text-xs font-medium bg-sage-100 text-sage-700 border border-sage-200 cursor-pointer hover:bg-sage-200 transition-colors"
|
||||
@click.prevent="$emit('click', tag)"
|
||||
>{{ tag }}</span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{ tag: string }>()
|
||||
defineEmits<{ click: [tag: string] }>()
|
||||
</script>
|
||||
@@ -0,0 +1,201 @@
|
||||
<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,240 @@
|
||||
.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; }
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
// 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 }
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* useFicheAdapter — B3-M2
|
||||
* Adaptateur léger Org | StructureV2 → MiniFiche, format commun consommé par
|
||||
* FicheMiniCard / FichesPanel. Aucune mutation des types sources.
|
||||
*
|
||||
* Repli résumé (brief B3, le champ `resume_court` du batch B2b n'existe pas
|
||||
* encore) : première phrase du premier champ non vide parmi une liste de
|
||||
* candidats ordonnée par type, tronquée proprement à ~110 caractères sur un
|
||||
* mot (jamais au milieu).
|
||||
*/
|
||||
import type { Org } from '~/types/org'
|
||||
import type { StructureV2 } from '~/types/structure-v2'
|
||||
|
||||
export interface MiniFiche {
|
||||
id: string | number
|
||||
nom: string
|
||||
resume: string
|
||||
chips: string[]
|
||||
echelle?: string
|
||||
geoloc: boolean
|
||||
kind: 'org' | 'structure'
|
||||
}
|
||||
|
||||
const RESUME_MAX = 110
|
||||
|
||||
/** Première phrase d'un texte (coupe sur ". "/"! "/"? " ou fin de chaîne). */
|
||||
function firstSentence(text: string): string {
|
||||
const match = text.match(/^.*?[.!?](?=\s|$)/)
|
||||
return (match ? match[0] : text).trim()
|
||||
}
|
||||
|
||||
/** Tronque proprement sur un mot, jamais au milieu. */
|
||||
function truncateOnWord(text: string, max = RESUME_MAX): string {
|
||||
if (text.length <= max) return text
|
||||
const slice = text.slice(0, max)
|
||||
const lastSpace = slice.lastIndexOf(' ')
|
||||
const cut = lastSpace > max * 0.4 ? slice.slice(0, lastSpace) : slice
|
||||
return `${cut.trimEnd()}…`
|
||||
}
|
||||
|
||||
function buildResume(candidates: Array<string | null | undefined>): string {
|
||||
const source = candidates.find((c) => !!c && c.trim().length > 0)
|
||||
if (!source) return ''
|
||||
return truncateOnWord(firstSentence(source.trim()))
|
||||
}
|
||||
|
||||
function isStructure(source: Org | StructureV2): source is StructureV2 {
|
||||
return 'famille_principale' in source
|
||||
}
|
||||
|
||||
function orgFonctions(org: Org): string[] {
|
||||
return (org.tags_fonction ?? '')
|
||||
.split(',')
|
||||
.map((f) => f.trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, 3)
|
||||
}
|
||||
|
||||
export function useFicheAdapter() {
|
||||
function toMini(source: Org | StructureV2): MiniFiche {
|
||||
if (isStructure(source)) {
|
||||
return {
|
||||
id: source.id,
|
||||
nom: source.nom,
|
||||
resume: buildResume([source.description_courte, source.description_longue]),
|
||||
chips: (source.hashtags ?? []).slice(0, 3),
|
||||
geoloc: source.latitude != null && source.longitude != null,
|
||||
kind: 'structure',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: source.Id,
|
||||
nom: source.nom,
|
||||
resume: buildResume([source.description_enrichie, source.description]),
|
||||
chips: orgFonctions(source),
|
||||
echelle: source.echelle,
|
||||
geoloc: source.latitude != null && source.longitude != null,
|
||||
kind: 'org',
|
||||
}
|
||||
}
|
||||
|
||||
return { toMini }
|
||||
}
|
||||
@@ -30,9 +30,9 @@ LOCAL_ENV_CONTENT=$(cat "$LOCAL_ENV" 2>/dev/null || echo "")
|
||||
if [ "$LOCAL_ENV_CONTENT" != "$REMOTE_ENV_CONTENT" ]; then
|
||||
log "AVERTISSEMENT : .env.production local != .env VPS"
|
||||
log " --- Local ---"
|
||||
echo "$LOCAL_ENV_CONTENT" | sed -E 's/(TOKEN|API_KEY|PASSWORD|SECRET)=.*$/\1=***/' | sed 's/^/ /'
|
||||
echo "$LOCAL_ENV_CONTENT" | sed 's/TOKEN=.*/TOKEN=***/' | sed 's/^/ /'
|
||||
log " --- VPS ---"
|
||||
echo "$REMOTE_ENV_CONTENT" | sed -E 's/(TOKEN|API_KEY|PASSWORD|SECRET)=.*$/\1=***/' | sed 's/^/ /'
|
||||
echo "$REMOTE_ENV_CONTENT" | sed 's/TOKEN=.*/TOKEN=***/' | sed 's/^/ /'
|
||||
read -p "Continuer malgre la difference ? [y/N] " CONFIRM
|
||||
[ "$CONFIRM" = "y" ] || { log "Deploiement annule."; exit 1; }
|
||||
fi
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,11 @@
|
||||
[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
|
||||
@@ -0,0 +1,9 @@
|
||||
[Unit]
|
||||
Description=Timer 15 min — worker enrichissement IA AEP
|
||||
|
||||
[Timer]
|
||||
OnCalendar=*:0/15
|
||||
Persistent=true
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
+21
-3
@@ -1,6 +1,20 @@
|
||||
export default defineNuxtConfig({
|
||||
modules: ['@nuxtjs/tailwindcss'],
|
||||
|
||||
app: {
|
||||
head: {
|
||||
link: [
|
||||
{ rel: 'icon', type: 'image/svg+xml', href: '/favicon.svg' },
|
||||
{ rel: 'icon', type: 'image/png', sizes: '32x32', href: '/favicon-32.png' },
|
||||
{ rel: 'icon', type: 'image/png', sizes: '16x16', href: '/favicon-16.png' },
|
||||
{ rel: 'apple-touch-icon', sizes: '180x180', href: '/apple-touch-icon.png' },
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
css: [
|
||||
'@transformer/ui/tokens.css',
|
||||
'@transformer/ui/theme/aep.css',
|
||||
'~/assets/css/main.css',
|
||||
'leaflet/dist/leaflet.css',
|
||||
'leaflet.markercluster/dist/MarkerCluster.css',
|
||||
@@ -16,7 +30,8 @@ export default defineNuxtConfig({
|
||||
commentTableId: process.env.COMMENT_TABLE_ID || process.env.AVIS_TABLE_ID,
|
||||
statsTableId: process.env.STATS_TABLE_ID || 'mbbq7n47ixy19mc',
|
||||
mistralApiKey: process.env.MISTRAL_API_KEY,
|
||||
nebiusApiKey: process.env.NEBIUS_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',
|
||||
resendApiKey: process.env.RESEND_API_KEY,
|
||||
emailJules: process.env.EMAIL_JULES || 'jules@trans-former.fr',
|
||||
@@ -24,14 +39,17 @@ export default defineNuxtConfig({
|
||||
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.NUXT_RAG_PE_URL || 'http://localhost:9621',
|
||||
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
|
||||
ssr: true,
|
||||
|
||||
vite: {
|
||||
cacheDir: 'C:/Users/jules/AppData/Local/nav-carte-vite-cache',
|
||||
optimizeDeps: {
|
||||
include: ['leaflet', 'leaflet.markercluster', 'd3'],
|
||||
},
|
||||
|
||||
Generated
+6
@@ -8,6 +8,7 @@
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@headlessui/vue": "^1.7.23",
|
||||
"@transformer/ui": "git+https://git.trans-former.fr/jules/design-system.git",
|
||||
"@types/leaflet": "^1.9.21",
|
||||
"@types/leaflet.markercluster": "^1.5.6",
|
||||
"d3": "^7.9.0",
|
||||
@@ -3625,6 +3626,11 @@
|
||||
"vue": "^2.7.0 || ^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@transformer/ui": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "git+https://git.trans-former.fr/jules/design-system.git#057de406c804315e09d28fb26f10941d61e461b1",
|
||||
"license": "UNLICENSED"
|
||||
},
|
||||
"node_modules/@tybys/wasm-util": {
|
||||
"version": "0.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@headlessui/vue": "^1.7.23",
|
||||
"@transformer/ui": "git+https://git.trans-former.fr/jules/design-system.git",
|
||||
"@types/leaflet": "^1.9.21",
|
||||
"@types/leaflet.markercluster": "^1.5.6",
|
||||
"d3": "^7.9.0",
|
||||
|
||||
+65
-66
@@ -13,7 +13,7 @@
|
||||
<section class="section-mission">
|
||||
<h1>À propos d'AEP</h1>
|
||||
<p class="mission-text">
|
||||
AEP — Architecture d'Écologie Politique — est un commun vivant : une infrastructure d'entraide, de ressources documentées et de cartographies au service d'une profession en mutation. Ce site rassemble trois cartes (entraide, réseaux engagés, plateformes de mise en relation), un manifeste, une transparence radicale sur l'IA et le financement, et une gouvernance partagée.
|
||||
AEP — Architecture d'Écologie Politique — est un commun vivant : une infrastructure d'entraide, de ressources documentées et de cartographies au service d'une profession en mutation. Ce site rassemble les outils, l'écosystème d'entraide, les réseaux AEP, les plateformes de mise en relation, le codev, la bibliothèque des pensées écologiques et les projets, un manifeste, une transparence radicale sur l'IA et le financement, et une gouvernance partagée.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
@@ -27,14 +27,6 @@
|
||||
Ce site tourne sur un VPS - un serveur privé loué en Europe - construit entièrement avec des logiciels open source, quasi gratuits. Pas de services Google, pas d'Amazon, pas de Microsoft dans la chaîne. C'est un choix délibéré : contrôler son infrastructure, c'est contrôler ses données.
|
||||
</p>
|
||||
<div class="badges-row">
|
||||
<div class="badge">
|
||||
<span class="badge-icon" aria-hidden="true">
|
||||
<!-- Mistral logo placeholder -->
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 8v4l3 3"/></svg>
|
||||
</span>
|
||||
<span class="badge-label">IA souveraine</span>
|
||||
<span class="badge-detail">Mistral AI, Paris - conforme RGPD ; pas de collecte ni de profiling ; aucun transfert hors UE</span>
|
||||
</div>
|
||||
<div class="badge">
|
||||
<span class="badge-icon" aria-hidden="true">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>
|
||||
@@ -103,22 +95,8 @@
|
||||
L'IA est un outil. La question, c'est au service de quoi. Oui, elle est énergivore - et il faut l'assumer. Mais si c'est au service de l'émancipation, du collectif, de transformer nos infrastructures sociétales, alors on dit oui - en sachant que c'est une étape transitoire. L'infrastructure de l'IA elle-même peut évoluer : devenir plus locale, plus sobre, plus souveraine. C'est dans cette direction qu'on regarde.
|
||||
</p>
|
||||
|
||||
<div class="ia-grid">
|
||||
<div class="ia-card">
|
||||
<div class="ia-card-label">Enrichissement des fiches</div>
|
||||
<div class="ia-card-model">Mistral Nemo</div>
|
||||
<div class="ia-card-cost">~0,00003 €/fiche</div>
|
||||
</div>
|
||||
<div class="ia-card">
|
||||
<div class="ia-card-label">Chatbot de recherche</div>
|
||||
<div class="ia-card-model">Mistral Small</div>
|
||||
<div class="ia-card-cost">~0,003 €/requête</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul class="ia-details">
|
||||
<li>10 € / mois investis au départ par le collectif - vos dons constituent la réserve pour faire vivre le projet et financer ses recherches</li>
|
||||
<li>Coût visible en direct dans le bandeau bas de la carte</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
@@ -190,12 +168,38 @@
|
||||
</a>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
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>
|
||||
|
||||
<style scoped>
|
||||
@@ -372,49 +376,6 @@ useHead({ title: 'À propos - AEP' })
|
||||
|
||||
/* ── IA grid ─────────────────────────────────────────────────────────────────── */
|
||||
|
||||
.ia-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.ia-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.ia-card {
|
||||
background: var(--nav-surface);
|
||||
border: 1px solid rgba(26, 34, 56, 0.1);
|
||||
border-radius: 10px;
|
||||
padding: 1rem 1.125rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.ia-card-label {
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--nav-text-muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ia-card-model {
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
color: var(--nav-text);
|
||||
}
|
||||
|
||||
.ia-card-cost {
|
||||
font-size: 0.825rem;
|
||||
color: var(--nav-text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.ia-details {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
@@ -512,6 +473,44 @@ useHead({ title: 'À propos - AEP' })
|
||||
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 ──────────────────────────────────────────────────────── */
|
||||
|
||||
@media (max-width: 480px) {
|
||||
|
||||
+110
-23
@@ -2,7 +2,15 @@
|
||||
<div class="flex h-full overflow-hidden" style="background: var(--nav-bg);">
|
||||
|
||||
<!-- ═══════════════════════════════════════ SIDEBAR DESKTOP (>= 1024px) -->
|
||||
<div class="hidden lg:block overflow-y-auto" style="width: 320px; min-width: 320px; flex-shrink: 0; border-right: 1px solid var(--nav-bg-alt); height: 100%;">
|
||||
<!-- Masquée en vue « Toutes les fiches » (B3-M3, décision Jules axe 2) :
|
||||
le panneau EST la liste (+ ses propres chips hashtags), la sidebar
|
||||
ferait doublon. IntentionBanner reste monté (Teleport, insensible au
|
||||
v-show de son conteneur). -->
|
||||
<div
|
||||
v-show="desktopMapView !== 'fiches'"
|
||||
class="hidden lg:block overflow-y-auto"
|
||||
style="width: 320px; min-width: 320px; flex-shrink: 0; border-right: 1px solid var(--nav-bg-alt); height: 100%;"
|
||||
>
|
||||
|
||||
<!-- IntentionBanner s'auto-affiche via Teleport (overlay plein ecran) -->
|
||||
<IntentionBanner />
|
||||
@@ -87,14 +95,15 @@
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-0.5 text-xs" style="color: var(--nav-text-muted);">{{ structure.type_principal }} - {{ structure.ville }}</div>
|
||||
<div v-if="structure.hashtags.length" class="mt-1 flex flex-wrap gap-1">
|
||||
<span
|
||||
<ChipGroup v-if="structure.hashtags.length" class="mt-1" :aria-label="`Hashtags de ${structure.nom}`">
|
||||
<Chip
|
||||
v-for="tag in structure.hashtags.slice(0, 2)"
|
||||
:key="tag"
|
||||
class="text-xs"
|
||||
style="color: var(--nav-text-muted);"
|
||||
>{{ tag }}</span>
|
||||
</div>
|
||||
as="span"
|
||||
variant="fonction"
|
||||
:label="tag"
|
||||
/>
|
||||
</ChipGroup>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -112,22 +121,29 @@
|
||||
:style="desktopMapView === 'metropole'
|
||||
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
|
||||
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
|
||||
@click="desktopMapView = 'metropole'"
|
||||
@click="setDesktopView('metropole')"
|
||||
>Métropolitain</button>
|
||||
<button
|
||||
class="px-5 py-2 text-sm font-medium transition-colors"
|
||||
:style="desktopMapView === 'outremer'
|
||||
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
|
||||
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
|
||||
@click="desktopMapView = 'outremer'"
|
||||
@click="setDesktopView('outremer')"
|
||||
>Outre-mer</button>
|
||||
<button
|
||||
class="px-5 py-2 text-sm font-medium transition-colors"
|
||||
:style="desktopMapView === 'graphe'
|
||||
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
|
||||
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
|
||||
@click="desktopMapView = 'graphe'"
|
||||
@click="setDesktopView('graphe')"
|
||||
>Vue graphique</button>
|
||||
<button
|
||||
class="px-5 py-2 text-sm font-medium transition-colors"
|
||||
:style="desktopMapView === 'fiches'
|
||||
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
|
||||
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
|
||||
@click="setDesktopView('fiches')"
|
||||
>Toutes les fiches</button>
|
||||
</div>
|
||||
|
||||
<!-- Carte Métropole desktop -->
|
||||
@@ -194,31 +210,53 @@
|
||||
@applyHashtag="(tag) => { if (!selectedHashtags.includes(tag)) selectedHashtags = [...selectedHashtags, tag] }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Panneau « Toutes les fiches » desktop (B3-M3) — la sidebar est
|
||||
masquée dans cette vue (décision Jules axe 2), le panneau EST
|
||||
la liste, avec ses propres chips hashtags sur le même état. -->
|
||||
<div v-show="desktopMapView === 'fiches'" class="flex-1 flex flex-col overflow-hidden">
|
||||
<FichesPanel
|
||||
:fiches="allStructuresMini"
|
||||
:hashtags="allHashtags"
|
||||
:selected="selectedHashtags"
|
||||
:query="search"
|
||||
intention="Toutes les agences et réseaux du répertoire, filtrables par hashtag."
|
||||
@update:selected="selectedHashtags = $event"
|
||||
@open="(fiche) => onSelectStructure(String(fiche.id))"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── VUE MOBILE : Onglets Métro/Outre-mer/Graphique + sheet swipable ── -->
|
||||
<!-- ── VUE MOBILE : Onglets Métro/Outre-mer/Graphique/Fiches + sheet swipable ── -->
|
||||
<div class="lg:hidden shrink-0 flex" style="background: var(--nav-surface); border-bottom: 1px solid var(--nav-bg-alt);">
|
||||
<button
|
||||
class="flex-1 py-2 text-sm font-medium transition-colors"
|
||||
:style="mobileMapView === 'metropole'
|
||||
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
|
||||
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
|
||||
@click="mobileMapView = 'metropole'"
|
||||
@click="setMobileView('metropole')"
|
||||
>Métropolitain</button>
|
||||
<button
|
||||
class="flex-1 py-2 text-sm font-medium transition-colors"
|
||||
:style="mobileMapView === 'outremer'
|
||||
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
|
||||
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
|
||||
@click="mobileMapView = 'outremer'"
|
||||
@click="setMobileView('outremer')"
|
||||
>Outre-mer</button>
|
||||
<button
|
||||
class="flex-1 py-2 text-sm font-medium transition-colors"
|
||||
:style="mobileMapView === 'graphe'
|
||||
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
|
||||
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
|
||||
@click="mobileMapView = 'graphe'"
|
||||
@click="setMobileView('graphe')"
|
||||
>Graphe</button>
|
||||
<button
|
||||
class="flex-1 py-2 text-sm font-medium transition-colors"
|
||||
:style="mobileMapView === 'fiches'
|
||||
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
|
||||
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
|
||||
@click="setMobileView('fiches')"
|
||||
>Fiches</button>
|
||||
</div>
|
||||
|
||||
<div class="lg:hidden flex-1 relative overflow-hidden">
|
||||
@@ -272,8 +310,30 @@
|
||||
</ClientOnly>
|
||||
</div>
|
||||
|
||||
<!-- Bottom sheet swipable (masqué en vue graphique pour ne pas occulter le canvas) -->
|
||||
<ClientOnly v-if="mobileMapView !== 'graphe'">
|
||||
<!-- Zone « Fiches » mobile (B3-M3) — pleine hauteur sous la barre d'onglets,
|
||||
pas de carte dessous donc pas de sheet demi-hauteur (même logique que
|
||||
la vue graphe qui masque déjà le MobileSheet). Fix 2026-09-25 : la
|
||||
version précédente empilait un second MobileSheet (état half par
|
||||
défaut) au-dessus d'un fond neutre plein écran, laissant ~45% de
|
||||
hauteur vide au-dessus du panneau. -->
|
||||
<div
|
||||
v-show="mobileMapView === 'fiches'"
|
||||
class="absolute inset-0 flex flex-col overflow-hidden"
|
||||
style="background: var(--nav-bg-alt);"
|
||||
>
|
||||
<FichesPanel
|
||||
:fiches="allStructuresMini"
|
||||
:hashtags="allHashtags"
|
||||
:selected="selectedHashtags"
|
||||
:query="search"
|
||||
intention="Toutes les agences et réseaux du répertoire, filtrables par hashtag."
|
||||
@update:selected="selectedHashtags = $event"
|
||||
@open="(fiche) => onSelectStructureMobile(String(fiche.id))"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Bottom sheet swipable (masqué en vue graphique et en vue fiches) -->
|
||||
<ClientOnly v-if="mobileMapView !== 'graphe' && mobileMapView !== 'fiches'">
|
||||
<MobileSheet :resultCount="filtered.length" :pending="pending">
|
||||
<!-- Bandeau intention mobile -->
|
||||
<div class="px-3 py-2" style="background: var(--bifurc-banner-bg, #faf8f5); border-bottom: 1px solid var(--bifurc-banner-border, #e0d8cc);">
|
||||
@@ -403,11 +463,6 @@
|
||||
@update:modelValue="chatbotOpen = $event"
|
||||
/>
|
||||
|
||||
<!-- ═══════════════════════════════════════ CHATBOT PENSEES (desktop, tous onglets) -->
|
||||
<ClientOnly>
|
||||
<ChatbotPensees />
|
||||
</ClientOnly>
|
||||
|
||||
<!-- ═══════════════════════════════════════ POP-UP MISSION RÉSEAUX AEP -->
|
||||
<button
|
||||
class="reseaux-info-btn"
|
||||
@@ -429,6 +484,7 @@
|
||||
title="Réseaux AEP — l'architecture qui s'engage"
|
||||
ctaLabel="Explorer les 120 réseaux"
|
||||
storageKey="aep_reseaux_seen"
|
||||
echelle="M"
|
||||
>
|
||||
<p class="mission-text">
|
||||
Cette carte rassemble <strong>120 réseaux, collectifs et agences</strong> qui pratiquent une architecture engagée — écologique, politique, biorégionale. Ce ne sont pas seulement des agences « vertes » : ce sont celles et ceux qui assument des positions, refusent des projets, expérimentent des modèles de gouvernance, mettent leurs ressources et leurs savoirs en commun.
|
||||
@@ -459,15 +515,41 @@ function familleColor(f: number): string {
|
||||
}
|
||||
|
||||
// ── État UI ────────────────────────────────────────────────────────────────
|
||||
type MapView = 'metropole' | 'outremer' | 'graphe' | 'fiches'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const selectedId = ref<string | null>(null)
|
||||
const hoveredId = ref<string | null>(null)
|
||||
const ficheModalOpen = ref(false)
|
||||
const ficheModalId = ref<string | null>(null)
|
||||
const chatbotOpen = ref(false)
|
||||
const mobileMapView = ref<'metropole' | 'outremer' | 'graphe'>('metropole')
|
||||
const desktopMapView = ref<'metropole' | 'outremer' | 'graphe'>('metropole')
|
||||
const mobileMapView = ref<MapView>((route.query.vue as string) === 'fiches' ? 'fiches' : 'metropole')
|
||||
const desktopMapView = ref<MapView>((route.query.vue as string) === 'fiches' ? 'fiches' : 'metropole')
|
||||
const missionOpen = ref(false)
|
||||
|
||||
// Sync URL — B3-M3 : pose/retire ?vue=fiches uniquement (agences.vue n'avait
|
||||
// aucune synchronisation URL avant B3 pour search/famille/hashtags/vue ; hors
|
||||
// périmètre de cette mission de l'étendre, seul ?vue=fiches est ajouté ici).
|
||||
function setDesktopView(v: MapView) {
|
||||
desktopMapView.value = v
|
||||
syncViewUrl()
|
||||
}
|
||||
function setMobileView(v: MapView) {
|
||||
mobileMapView.value = v
|
||||
syncViewUrl()
|
||||
}
|
||||
function syncViewUrl() {
|
||||
const q: Record<string, string> = { ...(route.query as Record<string, string>) }
|
||||
if (desktopMapView.value === 'fiches' || mobileMapView.value === 'fiches') {
|
||||
q.vue = 'fiches'
|
||||
} else {
|
||||
delete q.vue
|
||||
}
|
||||
router.replace({ query: Object.keys(q).length ? q : undefined })
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
try {
|
||||
if (!localStorage.getItem('aep_reseaux_seen')) {
|
||||
@@ -560,6 +642,11 @@ function resetFilters() {
|
||||
// Pour simplifier : toutes les structures (la carte gère les sans-coords)
|
||||
const metropoleStructures = computed<StructureV2[]>(() => filtered.value)
|
||||
|
||||
// Panneau « Toutes les fiches » (B3-M3) — mêmes structures que `filtered`,
|
||||
// sans filtrer par géolocalisation (adaptateur mutualisé avec `/`).
|
||||
const { toMini } = useFicheAdapter()
|
||||
const allStructuresMini = computed(() => filtered.value.map((s) => toMini(s)))
|
||||
|
||||
// Outre-mer : pas de structures V2 DOM-TOM pour l'instant - garder le composant existant vide
|
||||
// OutremerMap attend le format Org legacy - on passe un tableau vide
|
||||
const outremerOrgsLegacy = computed(() => [])
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
<div class="lock-header">
|
||||
<h1>Co-développement</h1>
|
||||
<p class="lock-subtitle">Entraide entre pairs</p>
|
||||
<EchelleTete echelle="M" compact :contribuer="false" class="lock-echelle" />
|
||||
<p class="lock-intro">Cet espace est un cercle. Pour entrer, il y a un mot.</p>
|
||||
</div>
|
||||
|
||||
@@ -104,6 +105,13 @@ async function submit() {
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
.lock-echelle {
|
||||
text-align: left;
|
||||
padding: var(--space-md);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-alt);
|
||||
}
|
||||
|
||||
.lock-intro {
|
||||
font-size: 0.9rem;
|
||||
color: var(--nav-text-muted, #6b7280);
|
||||
|
||||
+2
-790
@@ -1,795 +1,7 @@
|
||||
<template>
|
||||
<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>
|
||||
<div />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
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' })
|
||||
navigateTo('/proposer', { redirectCode: 301 })
|
||||
</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>
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
<!--
|
||||
Page de dev — story de test FicheMiniCard (B3-M2)
|
||||
Sert uniquement à la vérification visuelle. Aucun lien vers cette page
|
||||
nulle part dans l'app (pas dans app.vue, pas dans les pages) : navigation
|
||||
directe par URL uniquement (/dev/fiche-mini-card).
|
||||
-->
|
||||
<template>
|
||||
<div class="story">
|
||||
<h1 class="story__title">FicheMiniCard — story de test (B3-M2)</h1>
|
||||
|
||||
<section class="story__section">
|
||||
<h2 class="story__heading">Org — cas nominal</h2>
|
||||
<div class="story__grid">
|
||||
<FicheMiniCard :fiche="orgNominal" @open="log('org-nominal')" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="story__section">
|
||||
<h2 class="story__heading">Structure — cas nominal</h2>
|
||||
<div class="story__grid">
|
||||
<FicheMiniCard :fiche="structureNominal" @open="log('structure-nominal')" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="story__section">
|
||||
<h2 class="story__heading">Cas limites</h2>
|
||||
<div class="story__grid">
|
||||
<FicheMiniCard :fiche="nomLong" @open="log('nom-long')" />
|
||||
<FicheMiniCard :fiche="sansDescription" @open="log('sans-description')" />
|
||||
<FicheMiniCard :fiche="sansChips" @open="log('sans-chips')" />
|
||||
<FicheMiniCard :fiche="sansGeoloc" @open="log('sans-geoloc')" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="story__section">
|
||||
<h2 class="story__heading">Dernier événement « open »</h2>
|
||||
<p class="story__log">{{ lastOpen ?? '(aucun clic pour le moment)' }}</p>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { MiniFiche } from '~/composables/useFicheAdapter'
|
||||
|
||||
definePageMeta({ layout: false })
|
||||
|
||||
const lastOpen = ref<string | null>(null)
|
||||
function log(label: string) {
|
||||
lastOpen.value = label
|
||||
}
|
||||
|
||||
const orgNominal: MiniFiche = {
|
||||
id: 1,
|
||||
nom: 'Réseau Bâti Vivant',
|
||||
resume: 'Un réseau national d\'accompagnement juridique et technique pour les collectifs d\'auto-construction.',
|
||||
chips: ['Juridique', 'Technique'],
|
||||
echelle: 'National',
|
||||
geoloc: true,
|
||||
kind: 'org',
|
||||
}
|
||||
|
||||
const structureNominal: MiniFiche = {
|
||||
id: 'f1-rotor',
|
||||
nom: 'Rotor',
|
||||
resume: 'Rotor invente en Belgique le protocole de la déconstruction sélective comme geste politique contre la culture du tout-neuf.',
|
||||
chips: ['#reemploi-structurel', '#plateforme-materiaux'],
|
||||
geoloc: true,
|
||||
kind: 'structure',
|
||||
}
|
||||
|
||||
const nomLong: MiniFiche = {
|
||||
id: 2,
|
||||
nom: 'Collectif Régional de Réemploi et d\'Accompagnement des Chantiers Participatifs du Grand Ouest',
|
||||
resume: 'Nom volontairement long pour vérifier l\'ellipse sur une ligne.',
|
||||
chips: ['Chantier'],
|
||||
echelle: 'Régional',
|
||||
geoloc: true,
|
||||
kind: 'org',
|
||||
}
|
||||
|
||||
const sansDescription: MiniFiche = {
|
||||
id: 3,
|
||||
nom: 'Structure sans description',
|
||||
resume: '',
|
||||
chips: ['Formation'],
|
||||
echelle: 'Local',
|
||||
geoloc: true,
|
||||
kind: 'org',
|
||||
}
|
||||
|
||||
const sansChips: MiniFiche = {
|
||||
id: 4,
|
||||
nom: 'Structure sans chips',
|
||||
resume: 'Une organisation dont aucune fonction ni hashtag n\'est renseigné dans la source.',
|
||||
chips: [],
|
||||
geoloc: true,
|
||||
kind: 'org',
|
||||
}
|
||||
|
||||
const sansGeoloc: MiniFiche = {
|
||||
id: 5,
|
||||
nom: 'Structure sans localisation',
|
||||
resume: 'Une fiche nationale sans latitude ni longitude — invisible sur la carte, visible ici.',
|
||||
chips: ['Administratif', 'Comptabilité'],
|
||||
echelle: 'National',
|
||||
geoloc: false,
|
||||
kind: 'org',
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.story {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: var(--space-lg);
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: var(--font-body);
|
||||
}
|
||||
|
||||
.story__title {
|
||||
font-size: var(--fs-xl);
|
||||
font-weight: var(--fw-bold);
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
|
||||
.story__section {
|
||||
margin-bottom: var(--space-xl);
|
||||
}
|
||||
|
||||
.story__heading {
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: var(--fw-semibold);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
.story__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.story__log {
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
</style>
|
||||
+112
-41
@@ -40,7 +40,7 @@
|
||||
Mode dev — données seed
|
||||
</div>
|
||||
|
||||
<!-- ── VUE DESKTOP : Onglets Métropole / Outre-mer ── -->
|
||||
<!-- ── VUE DESKTOP : Onglets Métropole / Outre-mer / Toutes les fiches ── -->
|
||||
<div class="hidden lg:flex lg:flex-1 lg:flex-col lg:overflow-hidden">
|
||||
<!-- Barre onglets desktop -->
|
||||
<div class="shrink-0 flex" style="background: var(--nav-surface); border-bottom: 1px solid var(--nav-bg-alt);">
|
||||
@@ -49,15 +49,22 @@
|
||||
:style="desktopMapView === 'metropole'
|
||||
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
|
||||
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
|
||||
@click="desktopMapView = 'metropole'"
|
||||
@click="setDesktopView('metropole')"
|
||||
>Métropolitain</button>
|
||||
<button
|
||||
class="px-5 py-2 text-sm font-medium transition-colors"
|
||||
:style="desktopMapView === 'outremer'
|
||||
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
|
||||
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
|
||||
@click="desktopMapView = 'outremer'"
|
||||
@click="setDesktopView('outremer')"
|
||||
>Outre-mer</button>
|
||||
<button
|
||||
class="px-5 py-2 text-sm font-medium transition-colors"
|
||||
:style="desktopMapView === 'fiches'
|
||||
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
|
||||
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
|
||||
@click="setDesktopView('fiches')"
|
||||
>Toutes les fiches</button>
|
||||
</div>
|
||||
|
||||
<!-- Carte Métropole desktop -->
|
||||
@@ -94,26 +101,58 @@
|
||||
</div>
|
||||
<ChatbotPlaceholder @highlightOrgs="onHighlightOrgs" />
|
||||
</div>
|
||||
|
||||
<!-- Panneau « Toutes les fiches » desktop (B3-M3) — remplace la carte,
|
||||
fiches sans géoloc incluses (gain principal de cette mission) -->
|
||||
<div v-show="desktopMapView === 'fiches'" class="flex-1 flex flex-col overflow-hidden">
|
||||
<div
|
||||
class="shrink-0 px-4 py-2"
|
||||
style="border-bottom: 1px solid var(--nav-bg-alt); background: var(--nav-surface);"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="text-xs font-medium underline"
|
||||
style="color: var(--nav-text-muted);"
|
||||
@click="setDesktopView('metropole')"
|
||||
>← Carte</button>
|
||||
</div>
|
||||
<FichesPanel
|
||||
:fiches="allFichesMini"
|
||||
:hashtags="[...FONCTIONS]"
|
||||
:selected="fonctions"
|
||||
:query="search"
|
||||
intention="Toutes les structures d'entraide, y compris celles sans adresse sur la carte. Cochez une fonction pour filtrer."
|
||||
@update:selected="onFonctions"
|
||||
@open="(fiche) => onSelectOrg(Number(fiche.id))"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── VUE MOBILE : Onglets Métro/Outre-mer + carte pleine hauteur + sheet swipable ── -->
|
||||
|
||||
<!-- Onglets Métropolitain / Outre-mer -->
|
||||
<!-- Onglets Métropolitain / Outre-mer / Toutes les fiches -->
|
||||
<div class="lg:hidden shrink-0 flex" style="background: var(--nav-surface); border-bottom: 1px solid var(--nav-bg-alt);">
|
||||
<button
|
||||
class="flex-1 py-2 text-sm font-medium transition-colors"
|
||||
:style="mobileMapView === 'metropole'
|
||||
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
|
||||
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
|
||||
@click="mobileMapView = 'metropole'"
|
||||
@click="setMobileView('metropole')"
|
||||
>Métropolitain</button>
|
||||
<button
|
||||
class="flex-1 py-2 text-sm font-medium transition-colors"
|
||||
:style="mobileMapView === 'outremer'
|
||||
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
|
||||
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
|
||||
@click="mobileMapView = 'outremer'"
|
||||
@click="setMobileView('outremer')"
|
||||
>Outre-mer</button>
|
||||
<button
|
||||
class="flex-1 py-2 text-sm font-medium transition-colors"
|
||||
:style="mobileMapView === 'fiches'
|
||||
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
|
||||
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
|
||||
@click="setMobileView('fiches')"
|
||||
>Fiches</button>
|
||||
</div>
|
||||
|
||||
<div class="lg:hidden flex-1 relative overflow-hidden">
|
||||
@@ -154,8 +193,30 @@
|
||||
</ClientOnly>
|
||||
</div>
|
||||
|
||||
<!-- Bottom sheet swipable (Métropole et Outre-mer) -->
|
||||
<ClientOnly>
|
||||
<!-- Zone « Fiches » mobile (B3-M3) — pleine hauteur sous la barre d'onglets,
|
||||
pas de carte dessous donc pas de sheet demi-hauteur (même logique que
|
||||
la vue graphe qui masque aussi le MobileSheet). Fix 2026-09-25 : la
|
||||
version précédente empilait un second MobileSheet (état half par
|
||||
défaut) au-dessus d'un fond neutre plein écran, laissant ~45% de
|
||||
hauteur vide au-dessus du panneau. -->
|
||||
<div
|
||||
v-show="mobileMapView === 'fiches'"
|
||||
class="absolute inset-0 flex flex-col overflow-hidden"
|
||||
style="background: var(--nav-bg-alt);"
|
||||
>
|
||||
<FichesPanel
|
||||
:fiches="allFichesMini"
|
||||
:hashtags="[...FONCTIONS]"
|
||||
:selected="fonctions"
|
||||
:query="search"
|
||||
intention="Toutes les structures d'entraide, y compris celles sans adresse sur la carte. Cochez une fonction pour filtrer."
|
||||
@update:selected="onFonctions"
|
||||
@open="(fiche) => onSelectOrgMobile(Number(fiche.id))"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Bottom sheet swipable (Métropole et Outre-mer uniquement) -->
|
||||
<ClientOnly v-if="mobileMapView !== 'fiches'">
|
||||
<MobileSheet :resultCount="filtered.length" :pending="pending">
|
||||
<!-- Barre recherche -->
|
||||
<div class="px-3 pt-2 pb-2" style="border-bottom: 1px solid var(--nav-bg-alt);">
|
||||
@@ -185,45 +246,36 @@
|
||||
</button>
|
||||
</label>
|
||||
|
||||
<!-- Filtres ÉCHELLE — chips style FONCTION -->
|
||||
<!-- Filtres ÉCHELLE — Chip variant echelle (B3-M1 : alignées sur EchelleFilter.vue,
|
||||
dupliquées ici car la sheet mobile ne passe pas par NavSidebar) -->
|
||||
<div class="mt-2">
|
||||
<span class="text-xs font-bold uppercase tracking-wide block mb-1" style="color: var(--nav-text-muted);">ÉCHELLE</span>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<span
|
||||
<ChipGroup aria-label="Filtrer par échelle">
|
||||
<Chip
|
||||
v-for="opt in ECHELLES"
|
||||
:key="opt"
|
||||
class="cursor-pointer px-2 py-0.5 rounded-full text-xs transition-all"
|
||||
:style="echelle.includes(opt)
|
||||
? 'background: var(--nav-primary); color: var(--nav-text-on-primary); font-weight: 600;'
|
||||
: 'background: var(--nav-bg-alt); color: var(--nav-text-muted);'"
|
||||
@click="toggleEchelle(opt)"
|
||||
>{{ opt }}</span>
|
||||
</div>
|
||||
:label="opt"
|
||||
variant="echelle"
|
||||
:active="echelle.includes(opt)"
|
||||
@toggle="toggleEchelle(opt)"
|
||||
/>
|
||||
</ChipGroup>
|
||||
</div>
|
||||
|
||||
<!-- Filtres FONCTION — chips flex-wrap + toggle collapse -->
|
||||
<!-- Filtres FONCTION — Chip variant fonction (B3-M1, idem) -->
|
||||
<div class="mt-2">
|
||||
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 4px;">
|
||||
<span class="text-xs font-bold uppercase tracking-wide" style="color: var(--nav-text-muted);">
|
||||
FONCTION
|
||||
<span v-if="fonctions.length" style="font-weight: 400; text-transform: none; letter-spacing: 0; font-size: 0.65rem; margin-left: 4px;">({{ fonctions.length }} active{{ fonctions.length > 1 ? 's' : '' }})</span>
|
||||
</span>
|
||||
<button
|
||||
@click="mobileFonctionsOpen = !mobileFonctionsOpen"
|
||||
style="font-size: 0.65rem; color: var(--nav-text-muted); background: none; border: none; cursor: pointer; text-decoration: underline; padding: 0; white-space: nowrap;"
|
||||
>{{ mobileFonctionsOpen || fonctions.length ? (mobileFonctionsOpen ? 'Replier' : 'Afficher') : 'Fonctions (' + FONCTIONS.length + ')' }}</button>
|
||||
</div>
|
||||
<div v-if="mobileFonctionsOpen || fonctions.length" class="flex flex-wrap gap-1">
|
||||
<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">
|
||||
<Chip
|
||||
v-for="fn in FONCTIONS"
|
||||
:key="fn"
|
||||
class="cursor-pointer px-2 py-0.5 rounded-full text-xs transition-all"
|
||||
:style="fonctions.includes(fn)
|
||||
? 'background: var(--nav-primary); color: var(--nav-text-on-primary); font-weight: 600;'
|
||||
: 'background: var(--nav-bg-alt); color: var(--nav-text-muted);'"
|
||||
@click="toggleFonction(fn)"
|
||||
>{{ fn }}</span>
|
||||
</div>
|
||||
:label="fn"
|
||||
variant="fonction"
|
||||
:active="fonctions.includes(fn)"
|
||||
:count="fonctions.includes(fn) ? (fonctions.indexOf(fn) + 1) : (fonctionCount[fn] ?? 0)"
|
||||
@toggle="toggleFonction(fn)"
|
||||
/>
|
||||
</ChipGroup>
|
||||
</div>
|
||||
|
||||
<button
|
||||
@@ -339,6 +391,7 @@
|
||||
<MissionPopup
|
||||
:modelValue="missionOpen"
|
||||
@update:modelValue="missionOpen = $event"
|
||||
echelle="M"
|
||||
/>
|
||||
|
||||
</div>
|
||||
@@ -367,14 +420,15 @@ const territoireMode = ref<string>(
|
||||
(route.query.mode as string) === 'outremer' ? 'outremer' : 'metropole'
|
||||
)
|
||||
|
||||
const desktopMapView = ref<'metropole' | 'outremer'>('metropole')
|
||||
type MapView = 'metropole' | 'outremer' | 'fiches'
|
||||
|
||||
const desktopMapView = ref<MapView>((route.query.vue as string) === 'fiches' ? 'fiches' : 'metropole')
|
||||
const selectedId = ref<number | null>(null)
|
||||
const chatbotOpen = ref(false)
|
||||
const ficheModalOpen = ref(false)
|
||||
const ficheModalId = ref<number | null>(null)
|
||||
const mobileMapView = ref<'metropole' | 'outremer'>('metropole')
|
||||
const mobileMapView = ref<MapView>((route.query.vue as string) === 'fiches' ? 'fiches' : 'metropole')
|
||||
const missionOpen = ref(false)
|
||||
const mobileFonctionsOpen = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
try {
|
||||
@@ -421,9 +475,20 @@ function syncUrl() {
|
||||
if (fonctions.value.length) q.fonctions = fonctions.value.join(',')
|
||||
if (territoire.value) q.territoire = territoire.value
|
||||
if (territoireMode.value === 'outremer') q.mode = 'outremer'
|
||||
if (desktopMapView.value === 'fiches' || mobileMapView.value === 'fiches') q.vue = 'fiches'
|
||||
router.replace({ query: Object.keys(q).length ? q : undefined })
|
||||
}
|
||||
|
||||
// Changement de vue (carte/fiches) — passe par syncUrl pour poser/retirer ?vue=fiches
|
||||
function setDesktopView(v: MapView) {
|
||||
desktopMapView.value = v
|
||||
syncUrl()
|
||||
}
|
||||
function setMobileView(v: MapView) {
|
||||
mobileMapView.value = v
|
||||
syncUrl()
|
||||
}
|
||||
|
||||
// Sauvegarde les filtres courants dans sessionStorage pour le bouton retour des fiches
|
||||
function storeFiltersForBack() {
|
||||
if (typeof window === 'undefined') return
|
||||
@@ -551,6 +616,12 @@ const filtered = computed<Org[]>(() => {
|
||||
return result
|
||||
})
|
||||
|
||||
// Panneau « Toutes les fiches » (B3-M3) — mêmes fiches que `filtered`, sans
|
||||
// filtrer par géolocalisation : c'est le gain principal de cette mission,
|
||||
// les fiches sans latitude/longitude (invisibles sur la carte) y apparaissent.
|
||||
const { toMini } = useFicheAdapter()
|
||||
const allFichesMini = computed(() => filtered.value.map((org) => toMini(org)))
|
||||
|
||||
const DOM_TOM = ['Guadeloupe', 'Martinique', 'Guyane', 'La Réunion', 'Mayotte']
|
||||
const DOM_TOM_LIST = DOM_TOM
|
||||
|
||||
|
||||
+37
-571
@@ -1,590 +1,56 @@
|
||||
<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>
|
||||
|
||||
<!-- ZONE PRINCIPALE (pleine largeur, pas de sidebar) -->
|
||||
<main class="media-main">
|
||||
|
||||
<!-- Header onglet -->
|
||||
<div class="shrink-0 px-5 py-3"
|
||||
style="background: var(--nav-surface); border-bottom: 1px solid var(--nav-bg-alt); display:flex; align-items:center; justify-content:space-between; gap:12px;">
|
||||
<div>
|
||||
<h1 class="font-bold text-base" style="color: var(--nav-text);">ATIS Media</h1>
|
||||
<p class="text-xs mt-0.5" style="color: var(--nav-text-muted);">
|
||||
{{ corpusCount }} auteurs / {{ livresCount }} livres ingeres dans le RAG -
|
||||
<a href="https://bonpote.com/la-carte-des-pensees-ecologiques/"
|
||||
target="_blank" rel="noopener"
|
||||
style="color: var(--nav-primary, #3b6ea5); text-decoration: underline; text-underline-offset: 2px;">
|
||||
carte FRACAS Bonpote V2
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
@click="ragInfoOpen = true"
|
||||
title="A propos du RAG FRACAS"
|
||||
style="width:26px;height:26px;border-radius:50%;border:1.5px solid var(--nav-text-muted);color:var(--nav-text-muted);font-size:0.72rem;font-weight:700;cursor:pointer;flex-shrink:0;background:var(--nav-bg-alt);display:flex;align-items:center;justify-content:center;"
|
||||
aria-label="A propos du RAG">
|
||||
i
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Conteneur split / plein ecran -->
|
||||
<div class="layout-container">
|
||||
|
||||
<!-- Slot carte D3 -->
|
||||
<div
|
||||
class="carte-slot"
|
||||
:class="[
|
||||
layoutMode === 'split' ? 'carte-split' : '',
|
||||
layoutMode === 'carte-full' ? 'carte-full' : '',
|
||||
layoutMode === 'chatbot-full' ? 'carte-hidden' : '',
|
||||
]"
|
||||
:style="layoutMode === 'split' ? { flexBasis: carteFlexBasis } : {}"
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<!-- Barre de toggle -->
|
||||
<div class="layout-toggle-bar shrink-0">
|
||||
<button
|
||||
@click="setLayoutMode('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="setLayoutMode('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="setLayoutMode('chatbot-full')"
|
||||
:class="{ active: layoutMode === 'chatbot-full' }"
|
||||
class="toggle-btn"
|
||||
title="Chatbot 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">
|
||||
<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>
|
||||
<button
|
||||
@click="setLayoutMode('bonpote')"
|
||||
:class="{ active: layoutMode === 'bonpote' }"
|
||||
class="toggle-btn"
|
||||
title="A propos de la carte FRACAS Bonpote V2"
|
||||
style="margin-left: auto;"
|
||||
>
|
||||
<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">
|
||||
<circle cx="12" cy="12" r="10"/><polyline points="12 8 12 12 14 14"/>
|
||||
</svg>
|
||||
Bonpote V2
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Poignee draggable (visible uniquement en mode split, pas sur mobile) -->
|
||||
<div
|
||||
v-if="layoutMode === 'split'"
|
||||
class="split-handle"
|
||||
@mousedown.prevent="onHandleMousedown"
|
||||
title="Redimensionner"
|
||||
>
|
||||
<span class="split-handle-grip"></span>
|
||||
</div>
|
||||
|
||||
<!-- Slot chatbot inline -->
|
||||
<div
|
||||
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 V2 -->
|
||||
<div
|
||||
v-if="layoutMode === '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="setLayoutMode('split')"
|
||||
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>
|
||||
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- 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="A propos du RAG FRACAS">
|
||||
<div class="flex items-center justify-between px-5 py-4 shrink-0"
|
||||
style="border-bottom:2px solid var(--nav-bg-alt);background:var(--nav-surface);">
|
||||
<h2 class="font-bold text-base" style="color:var(--nav-text);">FRACAS - Bibliotheque des pensees ecologiques</h2>
|
||||
<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;">
|
||||
<p class="mb-3">Une bibliotheque parlante politisee - des pensees ecologiques de gauche, organisees pour aider a creer une pensee complexe et nuancee, critiquer le recit dominant et soutenir des alternatives concretes et des projets collectifs.</p>
|
||||
<p class="mb-4" style="color:var(--nav-text-muted);font-size:0.8rem;">Projet open source, ouvert a toutes et a tous - <a href="https://bonpote.com/la-carte-des-pensees-ecologiques/" target="_blank" rel="noopener" style="text-decoration:underline;">article + carte FRACAS Bonpote V2</a>.</p>
|
||||
<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>
|
||||
|
||||
<MediaTabVisuel v-if="tab === 'visuel'" />
|
||||
<MediaTabProjets v-else-if="tab === 'projets'" />
|
||||
</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[] }
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
type LayoutMode = 'split' | 'carte-full' | 'chatbot-full' | 'bonpote'
|
||||
const tab = ref<'visuel' | 'projets'>(
|
||||
(['visuel', 'projets'].includes(route.query.tab as string)
|
||||
? route.query.tab as 'visuel' | 'projets'
|
||||
: 'visuel')
|
||||
)
|
||||
|
||||
const STORAGE_KEY = 'media-layout-mode'
|
||||
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 penseesData = ref<PenseesData | null>(null)
|
||||
const layoutMode = ref<LayoutMode>('split')
|
||||
const cartePenseesRef = ref<{ triggerResize: () => void } | null>(null)
|
||||
|
||||
// Ratio de la carte vs chatbot en mode split (0.2 a 0.8)
|
||||
const splitRatio = ref(DEFAULT_SPLIT_RATIO)
|
||||
const carteFlexBasis = computed(() => `${splitRatio.value * 100}%`)
|
||||
const chatbotFlexBasis = computed(() => `${(1 - splitRatio.value) * 100}%`)
|
||||
|
||||
const corpusCount = computed(() => penseesData.value?.auteurs.filter((a: any) => a.ingere).length ?? 0)
|
||||
const livresCount = computed(() => {
|
||||
if (!penseesData.value) return 0
|
||||
const slugs = new Set<string>()
|
||||
penseesData.value.auteurs
|
||||
.filter((a: any) => a.ingere)
|
||||
.forEach((a: any) => (a.livres_rag ?? []).forEach((l: any) => slugs.add(l.slug)))
|
||||
return slugs.size
|
||||
watch(tab, (newTab) => {
|
||||
router.replace({ query: { ...route.query, tab: newTab } })
|
||||
})
|
||||
|
||||
// Logique poignee draggable
|
||||
let dragStartY = 0
|
||||
let dragStartRatio = DEFAULT_SPLIT_RATIO
|
||||
let containerHeight = 0
|
||||
|
||||
function onHandleMousedown(e: MouseEvent) {
|
||||
dragStartY = e.clientY
|
||||
dragStartRatio = splitRatio.value
|
||||
// Hauteur du layout-container (carte + handle + chatbot)
|
||||
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))
|
||||
}
|
||||
// Notifier D3 du resize apres relachement
|
||||
cartePenseesRef.value?.triggerResize()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const saved = localStorage.getItem(STORAGE_KEY) as LayoutMode | null
|
||||
if (saved && ['split', 'carte-full', 'chatbot-full', 'bonpote'].includes(saved)) {
|
||||
layoutMode.value = saved
|
||||
}
|
||||
const savedRatio = parseFloat(localStorage.getItem(SPLIT_RATIO_KEY) ?? '')
|
||||
if (!isNaN(savedRatio) && savedRatio >= 0.20 && savedRatio <= 0.80) {
|
||||
splitRatio.value = savedRatio
|
||||
}
|
||||
// Afficher le popup info RAG a la premiere visite
|
||||
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?v=4.2')
|
||||
} catch (e) {
|
||||
console.error('Erreur chargement auteurs-pensees.json', e)
|
||||
}
|
||||
})
|
||||
|
||||
// Persister + reset D3 apres transition
|
||||
function setLayoutMode(mode: LayoutMode) {
|
||||
layoutMode.value = mode
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem(STORAGE_KEY, mode)
|
||||
}
|
||||
// Restart simulation D3 apres la fin de la transition CSS (300ms)
|
||||
if (mode !== 'chatbot-full') {
|
||||
setTimeout(() => {
|
||||
cartePenseesRef.value?.triggerResize()
|
||||
}, 350)
|
||||
}
|
||||
}
|
||||
|
||||
watch(layoutMode, (v) => {
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem(STORAGE_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 (layoutMode.value === 'carte-full') setLayoutMode('split')
|
||||
}
|
||||
|
||||
function onInterrogerRag(auteurId: string) {
|
||||
ficheOpen.value = false
|
||||
const auteur = penseesData.value?.auteurs.find(a => a.id === auteurId)
|
||||
chatbotAuteur.value = auteur?.nom ?? null
|
||||
// Basculer en split pour que le chatbot soit visible
|
||||
if (layoutMode.value === 'carte-full') {
|
||||
setLayoutMode('split')
|
||||
}
|
||||
}
|
||||
|
||||
useHead({ title: 'AEP - Media - Carte FRACAS Bonpote' })
|
||||
useHead({ title: 'AEP - Media' })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Page container : flex column, prend toute la hauteur viewport */
|
||||
.media-page {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.media-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Conteneur des slots carte + toggle + chatbot */
|
||||
.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;
|
||||
}
|
||||
|
||||
/* --- Barre de toggle --- */
|
||||
.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;
|
||||
}
|
||||
|
||||
.toggle-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 0.75rem;
|
||||
.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;
|
||||
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);
|
||||
}
|
||||
|
||||
/* --- Poignee draggable entre carte et chatbot --- */
|
||||
.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
|
||||
);
|
||||
}
|
||||
|
||||
/* Masquer la poignee sur mobile (ratio fixe) */
|
||||
@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: 0 0 34%;
|
||||
min-height: 0;
|
||||
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); }
|
||||
|
||||
/* --- Responsive mobile (<768px) --- */
|
||||
/* Stack vertical : carte 60vh + chatbot 40vh en mode split */
|
||||
@media (max-width: 767px) {
|
||||
.carte-split {
|
||||
flex: 0 0 60vh;
|
||||
height: 60vh;
|
||||
}
|
||||
|
||||
.chatbot-split {
|
||||
flex: 0 0 calc(40vh - 38px);
|
||||
height: calc(40vh - 38px);
|
||||
}
|
||||
|
||||
.toggle-btn span,
|
||||
.toggle-btn {
|
||||
font-size: 0.7rem;
|
||||
padding: 3px 7px;
|
||||
}
|
||||
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>
|
||||
|
||||
@@ -0,0 +1,797 @@
|
||||
<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,248 @@
|
||||
<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>
|
||||
@@ -0,0 +1,3 @@
|
||||
<script setup lang="ts">
|
||||
navigateTo('/media', { redirectCode: 301 })
|
||||
</script>
|
||||
@@ -5,6 +5,7 @@
|
||||
<div class="taff-header">
|
||||
<div class="taff-header-inner">
|
||||
<h1 class="taff-title">Trouver du taf en archi</h1>
|
||||
<EchelleTete echelle="S" :contribuer="false" class="taff-echelle-tete" />
|
||||
<p class="taff-subtitle">
|
||||
Annuaire critique des plateformes de mise en relation et d'appels d'offres,
|
||||
pour les <strong>architectes indépendants</strong> — 70 % de la profession et sa part la plus précaire économiquement.
|
||||
@@ -296,7 +297,7 @@
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<p class="taff-chat-footer-note">Propulsé par Mistral · 20 questions/jour</p>
|
||||
<p class="taff-chat-footer-note">20 questions/jour</p>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
@@ -649,6 +650,10 @@ const parsedDescription = computed(() => {
|
||||
|
||||
.taff-header { padding: 2.5rem 1.5rem 1.5rem; border-bottom: 1px solid var(--nav-bg-alt); text-align: center; }
|
||||
.taff-header-inner { max-width: 680px; margin: 0 auto; }
|
||||
/* En-tête Jobs centré : la tête d'échelle suit. */
|
||||
.taff-echelle-tete { text-align: center; margin-bottom: var(--space-md); }
|
||||
.taff-echelle-tete :deep(.echelle-tete__kicker),
|
||||
.taff-echelle-tete :deep(.echelle-tete__mots) { justify-content: center; }
|
||||
.taff-title { font-size: 1.875rem; font-weight: 800; color: var(--nav-text); margin-bottom: 0.5rem; letter-spacing: -0.02em; }
|
||||
.taff-subtitle { font-size: 0.9375rem; color: var(--nav-text-muted); line-height: 1.6; margin-bottom: 0.625rem; }
|
||||
.taff-cible { font-size: 0.875rem; color: var(--nav-text-muted); line-height: 1.55; margin-bottom: 1rem; font-style: italic; }
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 6.1 KiB |
File diff suppressed because one or more lines are too long
@@ -9,11 +9,12 @@
|
||||
"note_doublons_en_fr": "3 livres avec version EN aussi indexee dans le RAG pour cross-language queries : carson-mer-autour-de-nous-fr/EN, graeber-wengrow-aurore-fr/EN, saito-capital-anthropocene/EN. JSON conserve la version FR.",
|
||||
"note_v2_1": "Phase 6 refonte Bonpote-aligned : 11 ecoles (fusion Marxismes-ecologiques -> Ecosocialisme, Marx+Saito migres), palette pastel, positions x_hint/y_hint Bonpote-aligned, labels affiches renommes (eco-anarchisme -> Eco-anarchisme, technocritique, ethique-env singulier)",
|
||||
"updated": "2026-05-14",
|
||||
"auteurs_ingeres_count": 80,
|
||||
"auteurs_ingeres_count": 91,
|
||||
"note_v3_0": "Phase 8.A sync corpus unifie : ~140 auteurs Bonpote integres, flag ingere:true/false selon LightRAG VPS. Auteurs non-ingeres = entrees minimales (bio provisoire, livres_rag vide), a enrichir lors de PRG-4/PRG-5.",
|
||||
"note_v3_1": "Phase 8.D transposition 1:1 Bonpote V2 : positions+couleurs Bonpote-aligned via OCR vision Sonnet, blur Voronoi, grisage auteurs non-ingeres",
|
||||
"note_v4_0": "Phase 8.G J+8 : corpus 226 docs / 62 auteurs ingeres. Noeuds-ecoles, popup info RAG, lien Bonpote, chatbots Nebius DeepSeek-V3.2.",
|
||||
"note_v4_1": "Phase 8.G J+8 fin batch : corpus 277 docs / 99 auteurs LightRAG. Mise a jour livres_rag depuis mapping heuristique ID -> slug. Modele ingestion : Qwen3-235B."
|
||||
"note_v4_1": "Phase 8.G J+8 fin batch : corpus 277 docs / 99 auteurs LightRAG. Mise a jour livres_rag depuis mapping heuristique ID -> slug. Modele ingestion : Qwen3-235B.",
|
||||
"note_2026_09_28": "Correction attribution : harvey-brief-history-neoliberalism était dupliqué (heritage du mapping heuristique v4.1) entre hal-harvey (capitalisme-vert, non ingéré) et david-harvey (eco-anarchisme, poster Bonpote). Doublon retiré chez hal-harvey, livre conservé uniquement chez david-harvey (ecoles inchangées, alignées sur le poster Bonpote). auteurs_ingeres_count recalculé par script depuis le contenu JSON (flags ingere:true sur les 171 auteurs) : 91 - remplace la valeur 80 ci-dessus, déjà obsolète avant cette correction (note_v4_1 annonçait 99 côté VPS ; écart à réconcilier séparément, hors périmètre). corpus_ingere (277) et livres_count (64) non recalculés : ils référencent l'état LightRAG VPS, non reproductibles depuis ce JSON seul (mapping heuristique partiel vers livres_rag, cf note_v4_1)."
|
||||
},
|
||||
"ecoles": [
|
||||
{
|
||||
@@ -408,8 +409,13 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"theses_cles_attendues": [],
|
||||
"bio_courte_provisoire": "Théoricien·ne présent·e sur le poster Bonpote (eco-anarchisme), non ingéré·e dans le RAG ATIS.",
|
||||
"theses_cles": [
|
||||
"Accumulation par dépossession",
|
||||
"Le néolibéralisme comme restauration du pouvoir de classe",
|
||||
"Droit à la ville et production capitaliste de l'espace"
|
||||
],
|
||||
"bio_courte": "Géographe marxiste britannique. A Brief History of Neoliberalism (2005) analyse le tournant néolibéral comme projet politique de restauration du pouvoir de la classe possédante, plus que comme doctrine économique neutre.",
|
||||
"note_correction": "Attribution corrigée le 2026-09-28 : le livre était associé par erreur à Hal Harvey (capitalisme-vert, poster Bonpote non ingéré) au lieu de David Harvey.",
|
||||
"ingere": true
|
||||
},
|
||||
{
|
||||
@@ -2124,19 +2130,11 @@
|
||||
"capitalisme-vert"
|
||||
],
|
||||
"ecole_principale": "capitalisme-vert",
|
||||
"livres_rag": [
|
||||
{
|
||||
"slug": "harvey-brief-history-neoliberalism",
|
||||
"titre": "brief history neoliberalism",
|
||||
"annee": 0,
|
||||
"couches": [
|
||||
"fond"
|
||||
]
|
||||
}
|
||||
],
|
||||
"livres_rag": [],
|
||||
"theses_cles_attendues": [],
|
||||
"bio_courte_provisoire": "Théoricien·ne présent·e sur le poster Bonpote (capitalisme-vert), non ingéré·e dans le RAG ATIS.",
|
||||
"ingere": true
|
||||
"note_correction": "Le livre harvey-brief-history-neoliberalism, attribué par erreur, a été retiré le 2026-09-28 (voir entrée david-harvey).",
|
||||
"ingere": false
|
||||
},
|
||||
{
|
||||
"id": "laurence-tubiana",
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
{
|
||||
"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." }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 527 B |
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,21 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" role="img" aria-label="AEP maillage">
|
||||
<rect width="32" height="32" rx="7" fill="#14142B"/>
|
||||
<g stroke="#E9A23B" stroke-width="1.4" stroke-linecap="round">
|
||||
<line x1="16" y1="6" x2="26.5" y2="13.5"/>
|
||||
<line x1="26.5" y1="13.5" x2="22.5" y2="26"/>
|
||||
<line x1="22.5" y1="26" x2="9.5" y2="26"/>
|
||||
<line x1="9.5" y1="26" x2="5.5" y2="13.5"/>
|
||||
<line x1="5.5" y1="13.5" x2="16" y2="6"/>
|
||||
<line x1="15" y1="16.5" x2="16" y2="6"/>
|
||||
<line x1="15" y1="16.5" x2="22.5" y2="26"/>
|
||||
<line x1="15" y1="16.5" x2="5.5" y2="13.5"/>
|
||||
</g>
|
||||
<g fill="#E9A23B">
|
||||
<circle cx="16" cy="6" r="2.4"/>
|
||||
<circle cx="26.5" cy="13.5" r="2.4"/>
|
||||
<circle cx="22.5" cy="26" r="2.4"/>
|
||||
<circle cx="9.5" cy="26" r="2.4"/>
|
||||
<circle cx="5.5" cy="13.5" r="2.4"/>
|
||||
</g>
|
||||
<circle cx="15" cy="16.5" r="2.9" fill="#F5C560"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 892 B |
@@ -0,0 +1,12 @@
|
||||
# Icons Credits
|
||||
|
||||
## outils-wrench.svg
|
||||
|
||||
**Source :** Heroicons — `wrench-screwdriver` icon
|
||||
**Licence :** MIT
|
||||
**URL :** https://heroicons.com/
|
||||
**Note :** Fallback Heroicons utilisé car l'API Noun Project a retourné 403 Forbidden au moment du build (2026-05-22). L'icône `wrench-screwdriver` de Heroicons (MIT) est une clé à molette + tournevis, style line art minimaliste, compatible avec l'identité visuelle AEP.
|
||||
|
||||
Si tu veux substituer par une icône Noun Project, utilise l'endpoint :
|
||||
`GET https://api.thenounproject.com/v2/icon?query=wrench&limit=20`
|
||||
avec les credentials OAuth 1.0a stockées dans `_System/API-credentials.md`.
|
||||
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M11.42 15.17 17.25 21A2.652 2.652 0 0 0 21 17.25l-5.877-5.877M11.42 15.17l2.496-3.03c.317-.384.74-.626 1.208-.766M11.42 15.17l-4.655 5.653a2.548 2.548 0 1 1-3.586-3.586l5.654-4.654m5.598-2.337 3.07-3.293a2.25 2.25 0 0 0-3.182-3.182l-3.293 3.07M6.75 12.75l-2.25.75.75-2.25 2.25-.75-.75 2.25Z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 493 B |
@@ -1,376 +0,0 @@
|
||||
// Build auteurs-pensees.json v3.0 — Phase 8.A
|
||||
// Sync corpus JSON unifié : Bonpote authors + LightRAG ingestion flags
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const JSON_PATH = 'C:\\Users\\jules\\Dropbox\\ATIS - IPCJRA\\1 PROJETS\\TECH - infra VPS, website pro, RAG\\nav-carte\\public\\data\\auteurs-pensees.json';
|
||||
|
||||
// === LightRAG slug prefixes (from /documents endpoint 2026-05-12) ===
|
||||
const LIGHTRAG_PREFIX_TO_AUTHOR_SLUG = {
|
||||
bookchin: 'murray-bookchin',
|
||||
brand: 'steward-brand',
|
||||
carson: 'rachel-carson',
|
||||
charbonneau: 'bernard-charbonneau',
|
||||
descola: 'philippe-descola',
|
||||
despret: 'vinciane-despret',
|
||||
eaubonne: 'francoise-deaubonne',
|
||||
ellul: 'jacques-ellul',
|
||||
federici: 'silvia-federici',
|
||||
ferdinand: 'malcolm-ferdinand',
|
||||
figueres: 'christiana-figueres',
|
||||
georgescu: 'nicholas-georgescu-roegen',
|
||||
gorz: 'andre-gorz',
|
||||
graeber: 'david-graeber',
|
||||
keith: 'david-keith',
|
||||
klein: 'naomi-klein',
|
||||
kropotkine: 'pierre-kropotkine',
|
||||
latouche: 'serge-latouche',
|
||||
latour: 'bruno-latour',
|
||||
lowy: 'michael-lowy',
|
||||
malm: 'andreas-malm',
|
||||
marx: 'karl-marx',
|
||||
meadows: 'donella-meadows',
|
||||
morizot: 'baptiste-morizot',
|
||||
naess: 'arne-naess',
|
||||
ouassak: 'fatima-ouassak',
|
||||
reclus: 'elisee-reclus',
|
||||
saito: 'kohei-saito',
|
||||
servigne: 'pablo-servigne',
|
||||
shiva: 'vandana-shiva',
|
||||
stengers: 'isabelle-stengers',
|
||||
vettese: 'troy-vettese',
|
||||
};
|
||||
|
||||
const INGESTED_AUTHOR_SLUGS = new Set(Object.values(LIGHTRAG_PREFIX_TO_AUTHOR_SLUG));
|
||||
|
||||
// === Bonpote authors (nom, dates, ecole_principale, ecoles_secondaires[]) ===
|
||||
const BONPOTE_AUTHORS = [
|
||||
// Éco-anarchisme
|
||||
['Pierre Kropotkine', '1842-1921', 'eco-anarchisme', []],
|
||||
['Élisée Reclus', '1830-1905', 'eco-anarchisme', []],
|
||||
['Murray Bookchin', '1921-2006', 'eco-anarchisme', []],
|
||||
['David Graeber', '1961-2020', 'eco-anarchisme', []],
|
||||
['James C. Scott', '1936-2024', 'eco-anarchisme', []],
|
||||
['Marshall Sahlins', '1930-2021', 'eco-anarchisme', []],
|
||||
['Pierre Clastres', '1934-1977', 'eco-anarchisme', []],
|
||||
['Cornélius Castoriadis', '1922-1997', 'eco-anarchisme', []],
|
||||
['David Harvey', '1935-', 'eco-anarchisme', ['ecosocialisme']],
|
||||
['Henri Lefebvre', '1901-1991', 'eco-anarchisme', ['ecosocialisme']],
|
||||
['Émile Gravelle', '1855-1920', 'eco-anarchisme', []],
|
||||
['Henri Zisly', '1872-1945', 'eco-anarchisme', []],
|
||||
['Edward Carpenter', '1844-1929', 'eco-anarchisme', []],
|
||||
['William Morris', '1834-1896', 'eco-anarchisme', []],
|
||||
['John Ruskin', '1819-1900', 'eco-anarchisme', []],
|
||||
['Kirkpatrick Sale', '1937-', 'eco-anarchisme', []],
|
||||
['Wendell Berry', '1934-', 'eco-anarchisme', []],
|
||||
['Kristin Ross', '1953-', 'eco-anarchisme', []],
|
||||
['Theodore Kaczynski', '1942-2023', 'eco-anarchisme', ['technocritique']],
|
||||
['Saint-Simon', '1760-1825', 'eco-anarchisme', []],
|
||||
['Auguste Comte', '1798-1857', 'eco-anarchisme', []],
|
||||
['Alberto Magnaghi', '1941-2023', 'eco-anarchisme', []],
|
||||
['Peter Berg', '1937-2011', 'eco-anarchisme', []],
|
||||
['Andreas Malm', '1977-', 'ecosocialisme', ['eco-anarchisme']],
|
||||
|
||||
// Écosocialisme
|
||||
['Karl Marx', '1818-1883', 'ecosocialisme', []],
|
||||
['Friedrich Engels', '1820-1895', 'ecosocialisme', []],
|
||||
['Rosa Luxemburg', '1871-1919', 'ecosocialisme', []],
|
||||
['Walter Benjamin', '1892-1940', 'ecosocialisme', []],
|
||||
['John Maynard Keynes', '1883-1946', 'ecosocialisme', []],
|
||||
['Pascal Lamy', '1947-', 'ecosocialisme', []],
|
||||
['Ann Pettifor', '1947-', 'ecosocialisme', []],
|
||||
['Holly Jean Buck', '', 'ecosocialisme', []],
|
||||
['Cédric Durand', '1975-', 'ecosocialisme', []],
|
||||
['Kim Stanley Robinson', '1952-', 'ecosocialisme', []],
|
||||
['André Gorz', '1923-2007', 'ecosocialisme', ['decroissance', 'technocritique']],
|
||||
['Kohei Saito', '1987-', 'ecosocialisme', ['decroissance']],
|
||||
['Razmig Keucheyan', '1975-', 'ecosocialisme', []],
|
||||
['Dominique Méda', '1962-', 'ecosocialisme', []],
|
||||
['Dominique Bourg', '1953-', 'ecosocialisme', []],
|
||||
['Troy Vettese', '', 'ecosocialisme', []],
|
||||
['Loïc Blondiaux', '1962-', 'ecosocialisme', []],
|
||||
['Drew Pendergrass', '', 'ecosocialisme', []],
|
||||
['Jason W. Moore', '', 'ecosocialisme', []],
|
||||
["James O'Connor", '1930-2017', 'ecosocialisme', []],
|
||||
['Herman Daly', '1938-2022', 'ecosocialisme', ['capitalisme-vert']],
|
||||
['John Bellamy Foster', '1953-', 'ecosocialisme', []],
|
||||
['Michael Löwy', '1938-', 'ecosocialisme', []],
|
||||
['Joel Kovel', '1936-2018', 'ecosocialisme', []],
|
||||
['Naomi Klein', '1970-', 'ecosocialisme', []],
|
||||
|
||||
// Technocritique
|
||||
['Jacques Ellul', '1912-1994', 'technocritique', []],
|
||||
['Bernard Charbonneau', '1910-1996', 'technocritique', []],
|
||||
['Lewis Mumford', '1895-1990', 'technocritique', []],
|
||||
['Alain Caillé', '1944-', 'technocritique', []],
|
||||
['Hans Jonas', '1903-1993', 'technocritique', ['ethiques-environnementales']],
|
||||
['Herbert Marcuse', '1898-1979', 'technocritique', []],
|
||||
['Günther Anders', '1902-1992', 'technocritique', []],
|
||||
['Pierre Fournier', '1937-1973', 'technocritique', []],
|
||||
['Alexandre Grothendieck', '1928-2014', 'technocritique', []],
|
||||
['Patrick Viveret', '1948-', 'technocritique', []],
|
||||
['Philippe Bihouix', '1971-', 'technocritique', []],
|
||||
['Jean Baudrillard', '1929-2007', 'technocritique', []],
|
||||
['Serge Latouche', '1940-', 'decroissance', ['technocritique']],
|
||||
['Ivan Illich', '1926-2002', 'technocritique', ['decroissance']],
|
||||
['Leopold Kohr', '1909-1994', 'technocritique', ['decroissance']],
|
||||
['Ernst Schumacher', '1911-1977', 'technocritique', ['decroissance']],
|
||||
['Nicholas Georgescu-Roegen', '1906-1994', 'decroissance', ['technocritique']],
|
||||
|
||||
// Écoféminismes
|
||||
["Françoise d'Eaubonne", '1920-2005', 'ecofeminismes', []],
|
||||
['Vandana Shiva', '1952-', 'ecofeminismes', ['ecologies-decoloniales']],
|
||||
['Starhawk', '1951-', 'ecofeminismes', []],
|
||||
['Ariel Salleh', '1944-', 'ecofeminismes', []],
|
||||
['Maria Mies', '1931-2023', 'ecofeminismes', []],
|
||||
['Carolyn Merchant', '1936-', 'ecofeminismes', []],
|
||||
['Silvia Federici', '1942-', 'ecofeminismes', []],
|
||||
['Val Plumwood', '1939-2008', 'ecofeminismes', []],
|
||||
['Susan Griffin', '1943-', 'ecofeminismes', []],
|
||||
['Veronika Bennholdt-Thomsen', '1944-', 'ecofeminismes', []],
|
||||
['Geneviève Pruvost', '1973-', 'ecofeminismes', []],
|
||||
['Donna Haraway', '1944-', 'ecofeminismes', ['pensees-vivant']],
|
||||
['Émilie Hache', '', 'ecofeminismes', []],
|
||||
['Joanna Macy', '1929-', 'ecofeminismes', ['ethiques-environnementales']],
|
||||
|
||||
// Capitalisme vert
|
||||
['Bill Gates', '1955-', 'capitalisme-vert', []],
|
||||
['Christiana Figueres', '1956-', 'capitalisme-vert', []],
|
||||
['Nicholas Stern', '1946-', 'capitalisme-vert', []],
|
||||
['Jeffrey Sachs', '1954-', 'capitalisme-vert', []],
|
||||
['Jared Diamond', '1937-', 'capitalisme-vert', ['decroissance']],
|
||||
['Jørgen Randers', '1945-', 'capitalisme-vert', ['decroissance']],
|
||||
['Donella Meadows', '1941-2001', 'decroissance', ['capitalisme-vert']],
|
||||
['Dennis Meadows', '1942-', 'decroissance', ['capitalisme-vert']],
|
||||
['Kate Raworth', '1970-', 'capitalisme-vert', []],
|
||||
['Al Gore', '1948-', 'capitalisme-vert', []],
|
||||
['Hal Harvey', '1960-', 'capitalisme-vert', []],
|
||||
['Laurence Tubiana', '1951-', 'capitalisme-vert', []],
|
||||
['Amory Lovins', '1947-', 'capitalisme-vert', []],
|
||||
['David Pearce', '1959-', 'capitalisme-vert', []],
|
||||
['Kerry Turner', '1948-', 'capitalisme-vert', []],
|
||||
['David Keith', '1963-', 'capitalisme-vert', []],
|
||||
['Ted Nordhaus', '1965-', 'capitalisme-vert', []],
|
||||
['Michael Shellenberger', '1971-', 'capitalisme-vert', []],
|
||||
['Pavan Sukhdev', '1960-', 'capitalisme-vert', []],
|
||||
['Janine Benyus', '1958-', 'capitalisme-vert', []],
|
||||
['Robert Costanza', '1950-', 'capitalisme-vert', []],
|
||||
['Peter Kareiva', '1951-', 'capitalisme-vert', []],
|
||||
['Michelle Marvier', '', 'capitalisme-vert', []],
|
||||
['Robert Lalasz', '1915-2003', 'capitalisme-vert', []],
|
||||
['Steward Brand', '1938-', 'capitalisme-vert', []],
|
||||
['Paul Crutzen', '1933-2021', 'capitalisme-vert', []],
|
||||
['Kenneth Boulding', '1910-1993', 'capitalisme-vert', []],
|
||||
['Eugene Odum', '1913-2002', 'capitalisme-vert', []],
|
||||
['Howard Odum', '1924-2002', 'capitalisme-vert', []],
|
||||
['Jean-Marc Jancovici', '1962-', 'capitalisme-vert', []],
|
||||
['Yves Cochet', '1946-', 'capitalisme-vert', ['decroissance']],
|
||||
['Pablo Servigne', '1978-', 'decroissance', ['capitalisme-vert']],
|
||||
['Gauthier Chapelle', '1968-', 'decroissance', ['capitalisme-vert']],
|
||||
|
||||
// Écologies décoloniales
|
||||
['Malcom Ferdinand', '1985-', 'ecologies-decoloniales', []],
|
||||
['Frantz Fanon', '1925-1961', 'ecologies-decoloniales', []],
|
||||
['Édouard Glissant', '1928-2011', 'ecologies-decoloniales', []],
|
||||
['Aimé Césaire', '1913-2008', 'ecologies-decoloniales', []],
|
||||
['Mohamad Amer Meziane', '', 'ecologies-decoloniales', []],
|
||||
['Chico Mendes', '1944-1988', 'ecologies-decoloniales', []],
|
||||
['Joan Martínez Alier', '1939-', 'ecologies-decoloniales', []],
|
||||
['Arturo Escobar', '1951-', 'ecologies-decoloniales', []],
|
||||
['Sous-commandant Marcos', '1957-', 'ecologies-decoloniales', []],
|
||||
['Alberto Acosta', '1948-', 'ecologies-decoloniales', []],
|
||||
['Jérôme Baschet', '1960-', 'ecologies-decoloniales', []],
|
||||
['Fatima Ouassak', '1976-', 'ecofeminismes', ['ecologies-decoloniales']],
|
||||
['William Acker', '1991-', 'ecologies-decoloniales', []],
|
||||
['Giorgos Kallis', '1972-', 'ecologies-decoloniales', ['decroissance']],
|
||||
['Bernard Lambert', '1931-1984', 'ecologies-decoloniales', []],
|
||||
|
||||
// Écofascismes
|
||||
['Alain de Benoist', '1943-', 'ecofascismes', []],
|
||||
['Paul Ralph Ehrlich', '1932-', 'ecofascismes', []],
|
||||
['Garrett Hardin', '1915-2003', 'ecofascismes', []],
|
||||
['Edward Osborne Wilson', '1929-2021', 'ecofascismes', []],
|
||||
['Thomas Malthus', '1803-1882', 'ecofascismes', []],
|
||||
['David Foreman', '1946-2022', 'ecofascismes', []],
|
||||
['Piero San Giorgio', '1971-', 'ecofascismes', []],
|
||||
|
||||
// Éthique environnementale
|
||||
['Arne Næss', '1912-2009', 'ethiques-environnementales', []],
|
||||
['Rachel Carson', '1907-1964', 'ethiques-environnementales', []],
|
||||
['Aldo Leopold', '1887-1948', 'ethiques-environnementales', []],
|
||||
['Imanishi Kinji', '1902-1992', 'ethiques-environnementales', []],
|
||||
['Paul Watson', '1950-', 'ethiques-environnementales', []],
|
||||
['John Muir', '1838-1914', 'ethiques-environnementales', []],
|
||||
['Edward Abbey', '1927-1989', 'ethiques-environnementales', []],
|
||||
['John Baird Callicott', '1941-', 'ethiques-environnementales', []],
|
||||
['Bill Mollison', '1928-2016', 'ethiques-environnementales', []],
|
||||
['David Holmgren', '1955-', 'ethiques-environnementales', []],
|
||||
['Peter Singer', '1946-', 'ethiques-environnementales', []],
|
||||
['Pierre Rabhi', '1938-2021', 'ethiques-environnementales', []],
|
||||
['Rob Hopkins', '1968-', 'ethiques-environnementales', []],
|
||||
['Cyril Dion', '1978-', 'ethiques-environnementales', []],
|
||||
['Gandhi', '1869-1948', 'ethiques-environnementales', []],
|
||||
['Gifford Pinchot', '1865-1946', 'ethiques-environnementales', []],
|
||||
['Lanza del Vasto', '1901-1981', 'ethiques-environnementales', []],
|
||||
['Jorge Mario Bergoglio', '1936-', 'ethiques-environnementales', []],
|
||||
['Gary Snyder', '1930-', 'ethiques-environnementales', []],
|
||||
['Henry David Thoreau', '1817-1862', 'ethiques-environnementales', []],
|
||||
['Ralph Waldo Emerson', '1803-1882', 'ethiques-environnementales', []],
|
||||
['José Bové', '1953-', 'ethiques-environnementales', []],
|
||||
['Glenn Albrecht', '1953-', 'ethiques-environnementales', []],
|
||||
|
||||
// Pensées du vivant
|
||||
['Bruno Latour', '1947-2022', 'pensees-vivant', []],
|
||||
['Isabelle Stengers', '1949-', 'pensees-vivant', []],
|
||||
['Vinciane Despret', '1959-', 'pensees-vivant', []],
|
||||
['Baptiste Morizot', '1983-', 'pensees-vivant', []],
|
||||
['Philippe Descola', '1949-', 'pensees-vivant', []],
|
||||
['Eduardo Viveiros de Castro', '1951-', 'pensees-vivant', []],
|
||||
['Anna Tsing', '1952-', 'pensees-vivant', []],
|
||||
['Deborah Bird Rose', '1946-2018', 'pensees-vivant', []],
|
||||
['Lynn Margulis', '1938-2011', 'pensees-vivant', []],
|
||||
['James Lovelock', '1919-2022', 'pensees-vivant', []],
|
||||
['Serge Moscovici', '1925-2014', 'pensees-vivant', []],
|
||||
['Theodore Roszak', '1933-2011', 'pensees-vivant', []],
|
||||
['Baruch Spinoza', '1632-1677', 'pensees-vivant', []],
|
||||
];
|
||||
|
||||
// Special slug overrides (match v2.1 IDs + ligatures)
|
||||
const NAME_TO_SLUG_OVERRIDES = {
|
||||
'Malcom Ferdinand': 'malcolm-ferdinand',
|
||||
"Françoise d'Eaubonne": 'francoise-deaubonne',
|
||||
'Donella Meadows': 'donella-meadows',
|
||||
'Dennis Meadows': 'dennis-meadows',
|
||||
'Arne Næss': 'arne-naess',
|
||||
'Jørgen Randers': 'jorgen-randers',
|
||||
};
|
||||
|
||||
function slugify(name) {
|
||||
// Pre-process special ligatures and chars not handled by NFKD
|
||||
let pre = name
|
||||
.replace(/[æÆ]/g, 'ae')
|
||||
.replace(/[øØ]/g, 'o')
|
||||
.replace(/[œŒ]/g, 'oe')
|
||||
.replace(/ß/g, 'ss');
|
||||
// Remove diacritical marks
|
||||
const noAccent = pre.normalize('NFKD').replace(/[̀-ͯ]/g, '');
|
||||
return noAccent
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
}
|
||||
|
||||
function getAuthorSlug(name) {
|
||||
if (NAME_TO_SLUG_OVERRIDES[name]) return NAME_TO_SLUG_OVERRIDES[name];
|
||||
return slugify(name);
|
||||
}
|
||||
|
||||
function main() {
|
||||
const raw = fs.readFileSync(JSON_PATH, 'utf-8');
|
||||
const data = JSON.parse(raw);
|
||||
|
||||
const existingBySlug = {};
|
||||
for (const a of data.auteurs) existingBySlug[a.id] = a;
|
||||
|
||||
const newAuthors = [];
|
||||
const seenSlugs = new Set();
|
||||
|
||||
for (const [nom, dates, ecolePrincipale, ecolesSecondaires] of BONPOTE_AUTHORS) {
|
||||
const slug = getAuthorSlug(nom);
|
||||
if (seenSlugs.has(slug)) {
|
||||
console.error(`DUPLICATE SKIP: ${nom} -> ${slug}`);
|
||||
continue;
|
||||
}
|
||||
seenSlugs.add(slug);
|
||||
|
||||
const ingere = INGESTED_AUTHOR_SLUGS.has(slug);
|
||||
const ecoles = [ecolePrincipale, ...ecolesSecondaires];
|
||||
|
||||
if (existingBySlug[slug]) {
|
||||
// Preserve enriched entry
|
||||
const entry = { ...existingBySlug[slug], ingere };
|
||||
newAuthors.push(entry);
|
||||
} else {
|
||||
// New minimal entry
|
||||
const bioProvisoire = ingere
|
||||
? `Auteur·ice ingéré·e dans le RAG ATIS, bio à enrichir lors de PRG-5.`
|
||||
: `Théoricien·ne présent·e sur le poster Bonpote (${ecolePrincipale}), non ingéré·e dans le RAG ATIS.`;
|
||||
newAuthors.push({
|
||||
id: slug,
|
||||
nom,
|
||||
dates,
|
||||
ecoles,
|
||||
ecole_principale: ecolePrincipale,
|
||||
livres_rag: [],
|
||||
theses_cles_attendues: [],
|
||||
bio_courte_provisoire: bioProvisoire,
|
||||
ingere,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Preserve any v2.1 author not in Bonpote list
|
||||
for (const [slug, entry] of Object.entries(existingBySlug)) {
|
||||
if (!seenSlugs.has(slug)) {
|
||||
const copy = { ...entry };
|
||||
if (!('ingere' in copy)) copy.ingere = INGESTED_AUTHOR_SLUGS.has(slug);
|
||||
newAuthors.push(copy);
|
||||
seenSlugs.add(slug);
|
||||
console.error(`NOTE: preserved v2.1 author not in Bonpote canonical: ${slug}`);
|
||||
}
|
||||
}
|
||||
|
||||
const auteursCount = newAuthors.length;
|
||||
const auteursIngeresCount = newAuthors.filter(a => a.ingere).length;
|
||||
|
||||
data.meta.version = '3.0';
|
||||
data.meta.updated = '2026-05-12';
|
||||
data.meta.auteurs_count = auteursCount;
|
||||
data.meta.auteurs_ingeres_count = auteursIngeresCount;
|
||||
data.meta.source = 'FRACAS Bonpote V2 oct 2024 + LightRAG corpus 12/05/2026 (v3.0 sync)';
|
||||
data.meta.note_v3_0 = 'Phase 8.A sync corpus unifie : ~140 auteurs Bonpote integres, flag ingere:true/false selon LightRAG VPS. Auteurs non-ingeres = entrees minimales (bio provisoire, livres_rag vide), a enrichir lors de PRG-4/PRG-5.';
|
||||
|
||||
data.auteurs = newAuthors;
|
||||
|
||||
fs.writeFileSync(JSON_PATH, JSON.stringify(data, null, 2), 'utf-8');
|
||||
|
||||
// Validate parse-back
|
||||
const parsedBack = JSON.parse(fs.readFileSync(JSON_PATH, 'utf-8'));
|
||||
if (parsedBack.auteurs.length !== auteursCount) {
|
||||
console.error('PARSE-BACK MISMATCH');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Stats
|
||||
const schoolsStats = {};
|
||||
for (const a of newAuthors) {
|
||||
const ep = a.ecole_principale || '?';
|
||||
if (!schoolsStats[ep]) schoolsStats[ep] = { total: 0, ingere: 0 };
|
||||
schoolsStats[ep].total++;
|
||||
if (a.ingere) schoolsStats[ep].ingere++;
|
||||
}
|
||||
|
||||
console.log('\n=== JSON v3.0 written ===');
|
||||
console.log(`Total auteurs : ${auteursCount}`);
|
||||
console.log(`Ingeres : ${auteursIngeresCount}`);
|
||||
console.log(`Non-ingeres : ${auteursCount - auteursIngeresCount}`);
|
||||
console.log(`Parse-back : OK (${parsedBack.auteurs.length} auteurs)`);
|
||||
console.log('\nPer school (ecole_principale):');
|
||||
const sortedSchools = Object.entries(schoolsStats).sort((a, b) => b[1].total - a[1].total);
|
||||
for (const [school, st] of sortedSchools) {
|
||||
console.log(` ${school.padEnd(30)} total=${String(st.total).padStart(3)} ingere=${String(st.ingere).padStart(3)} non-ing=${String(st.total - st.ingere).padStart(3)}`);
|
||||
}
|
||||
|
||||
// Top 5 schools with most non-ingested
|
||||
const nonIngStats = sortedSchools
|
||||
.map(([k, v]) => [k, v.total - v.ingere])
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 5);
|
||||
console.log('\nTop 5 ecoles avec le plus de non-ingeres (PRG-4 priorities):');
|
||||
for (const [school, count] of nonIngStats) {
|
||||
console.log(` ${school.padEnd(30)} non-ing=${count}`);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,140 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Test du schéma Zod du formulaire assoupli (B5-M1).
|
||||
* Charge le VRAI schéma TS (utils/submitLibre.ts) via jiti (déjà une dépendance
|
||||
* transitive de Nuxt, pas de nouveau paquet) — pas de réimplémentation parallèle.
|
||||
* Usage : node scripts/test-submit-libre.mjs
|
||||
*/
|
||||
|
||||
import { createJiti } from 'jiti'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { dirname, join } from 'node:path'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const jiti = createJiti(import.meta.url)
|
||||
|
||||
const { LibreSubmitSchema, toOrgaPayload, toReferencePayload } = await jiti.import(
|
||||
join(__dirname, '..', 'utils', 'submitLibre.ts'),
|
||||
)
|
||||
|
||||
function baseInput(overrides) {
|
||||
return { liens: [], texte: '', site_web: '', ...overrides }
|
||||
}
|
||||
|
||||
const cases = [
|
||||
{
|
||||
name: 'vide (aucun lien, aucun texte) → refus',
|
||||
input: baseInput({}),
|
||||
expectOk: false,
|
||||
},
|
||||
{
|
||||
name: '1 lien seul → ok',
|
||||
input: baseInput({ liens: ['https://exemple.fr'] }),
|
||||
expectOk: true,
|
||||
},
|
||||
{
|
||||
name: 'texte seul → ok',
|
||||
input: baseInput({ texte: "Ça m'a beaucoup aidé pour ma reconversion." }),
|
||||
expectOk: true,
|
||||
},
|
||||
{
|
||||
name: '11 liens → refus',
|
||||
input: baseInput({ liens: Array.from({ length: 11 }, (_, i) => `https://exemple${i}.fr`) }),
|
||||
expectOk: false,
|
||||
},
|
||||
{
|
||||
name: 'URL invalide (pas de schéma) → refus',
|
||||
input: baseInput({ liens: ['exemple.fr'] }),
|
||||
expectOk: false,
|
||||
},
|
||||
{
|
||||
name: 'ftp:// → refus',
|
||||
input: baseInput({ liens: ['ftp://exemple.fr/fichier.pdf'] }),
|
||||
expectOk: false,
|
||||
},
|
||||
{
|
||||
name: 'double type valide (jobs + outils) → ok',
|
||||
input: baseInput({ liens: ['https://exemple.fr'], type_suggere: 'jobs', type_secondaire: 'outils' }),
|
||||
expectOk: true,
|
||||
},
|
||||
{
|
||||
name: 'type_secondaire sans type_suggere → refus',
|
||||
input: baseInput({ liens: ['https://exemple.fr'], type_secondaire: 'outils' }),
|
||||
expectOk: false,
|
||||
},
|
||||
{
|
||||
name: 'type_secondaire = type_suggere → refus',
|
||||
input: baseInput({ liens: ['https://exemple.fr'], type_suggere: 'jobs', type_secondaire: 'jobs' }),
|
||||
expectOk: false,
|
||||
},
|
||||
{
|
||||
name: 'type inconnu → refus',
|
||||
input: baseInput({ liens: ['https://exemple.fr'], type_suggere: 'inconnu' }),
|
||||
expectOk: false,
|
||||
},
|
||||
]
|
||||
|
||||
let failures = 0
|
||||
|
||||
for (const c of cases) {
|
||||
const result = LibreSubmitSchema.safeParse(c.input)
|
||||
const pass = result.success === c.expectOk
|
||||
const detail = result.success ? '' : ` (${JSON.stringify(result.error.flatten().fieldErrors)})`
|
||||
console.log(`${pass ? 'PASS' : 'FAIL'} — ${c.name}${pass ? '' : detail}`)
|
||||
if (!pass) failures++
|
||||
}
|
||||
|
||||
// ── Mappings (toOrgaPayload / toReferencePayload) ──────────────────────────
|
||||
function checkMapping(name, ok) {
|
||||
console.log(`${ok ? 'PASS' : 'FAIL'} — ${name}`)
|
||||
if (!ok) failures++
|
||||
}
|
||||
|
||||
{
|
||||
const data = LibreSubmitSchema.parse(baseInput({ liens: ['https://exemple.fr'] }))
|
||||
const payload = toOrgaPayload(data)
|
||||
checkMapping(
|
||||
'aucun type → description_user commence par « Type : non précisé »',
|
||||
payload.description_user.startsWith('Type : non précisé'),
|
||||
)
|
||||
}
|
||||
|
||||
{
|
||||
const data = LibreSubmitSchema.parse(baseInput({ liens: ['https://exemple.fr'], type_suggere: 'jobs' }))
|
||||
const payload = toOrgaPayload(data)
|
||||
checkMapping(
|
||||
'un type → pas de marqueur ni de ligne « Type suggéré »',
|
||||
!payload.description_user.startsWith('Type : non précisé') && !payload.description_user.includes('Type suggéré'),
|
||||
)
|
||||
}
|
||||
|
||||
{
|
||||
const data = LibreSubmitSchema.parse(
|
||||
baseInput({ liens: ['https://exemple.fr'], type_suggere: 'jobs', type_secondaire: 'outils' }),
|
||||
)
|
||||
const payload = toOrgaPayload(data)
|
||||
checkMapping(
|
||||
'double type → description_user commence par « Type suggéré : Jobs ou Outils »',
|
||||
payload.description_user.startsWith('Type suggéré : Jobs ou Outils'),
|
||||
)
|
||||
checkMapping('double type → submission_type = le premier (job)', payload.submission_type === 'job')
|
||||
checkMapping(
|
||||
'double type → ne commence PAS par « Type : non précisé »',
|
||||
!payload.description_user.startsWith('Type : non précisé'),
|
||||
)
|
||||
}
|
||||
|
||||
{
|
||||
const data = LibreSubmitSchema.parse(
|
||||
baseInput({ liens: ['https://exemple.fr'], type_suggere: 'references', type_secondaire: 'outils' }),
|
||||
)
|
||||
const payload = toReferencePayload(data)
|
||||
checkMapping(
|
||||
'premier = Références + second → description commence par « Type suggéré : Références ou Outils »',
|
||||
payload.description.startsWith('Type suggéré : Références ou Outils'),
|
||||
)
|
||||
}
|
||||
|
||||
const totalMappingChecks = 6
|
||||
console.log(`\n${failures === 0 ? 'PASS' : 'FAIL'} — ${cases.length + totalMappingChecks - failures}/${cases.length + totalMappingChecks} tests passés`)
|
||||
process.exit(failures > 0 ? 1 : 0)
|
||||
@@ -1,35 +1,12 @@
|
||||
import type { H3Event } from 'h3'
|
||||
/**
|
||||
* POST /api/chatbot-pensees
|
||||
* Chatbot RAG Pensées Écologiques — corpus FRACAS Bonpote
|
||||
* Appelle LightRAG PE (localhost:9621) avec contexte auteur optionnel.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { checkRateLimitJson } from '~/server/utils/rateLimitJson'
|
||||
|
||||
interface ChatbotPenseesRequest {
|
||||
query: string
|
||||
mode?: 'hybrid' | 'local' | 'global' | 'naive' | 'mix'
|
||||
corpus?: 'pensees' | 'projets' | 'both'
|
||||
filter_couche?: 'fond' | 'forme' | 'structure' | null
|
||||
filter_ecole?: string | null
|
||||
auteur_slug?: string | null
|
||||
history?: Array<{ role: 'user' | 'assistant'; content: string }>
|
||||
}
|
||||
|
||||
interface LightRAGReference {
|
||||
reference_id?: string
|
||||
file_path?: string
|
||||
content?: string | null
|
||||
}
|
||||
|
||||
interface LightRAGQueryResponse {
|
||||
response: string
|
||||
references?: LightRAGReference[]
|
||||
}
|
||||
|
||||
interface AuteurMini {
|
||||
id: string
|
||||
nom: string
|
||||
ingere?: boolean
|
||||
}
|
||||
|
||||
const SYSTEM_PREFACE_PENSEES = `Tu es un agent du RAG Pensées Écologiques, infrastructure militante du collectif trans-former.fr.
|
||||
Tu réponds en t'appuyant STRICTEMENT sur le corpus ingéré (auteurs FRACAS Bonpote : écosocialisme, éco-anarchisme, écoféminismes, écologies décoloniales, technocritique, pensées du vivant, décroissance...).
|
||||
|
||||
@@ -75,15 +52,17 @@ Règles :
|
||||
- Réponse en français, dense, sans délayage.`
|
||||
}
|
||||
|
||||
// Chargement (et cache) de la liste des auteurs ingérés pour validation du slug
|
||||
let auteursIngeresCache: AuteurMini[] | null = null
|
||||
function loadAuteursIngeres(): AuteurMini[] {
|
||||
interface AuteurIngere { id: string; nom: string; ingere: boolean }
|
||||
|
||||
let auteursIngeresCache: AuteurIngere[] | null = null
|
||||
|
||||
function loadAuteursIngeres(): AuteurIngere[] {
|
||||
if (auteursIngeresCache) return auteursIngeresCache
|
||||
try {
|
||||
const jsonPath = join(process.cwd(), 'public', 'data', 'auteurs-pensees.json')
|
||||
const raw = readFileSync(jsonPath, 'utf-8')
|
||||
const data = JSON.parse(raw)
|
||||
const list: AuteurMini[] = (data.auteurs ?? [])
|
||||
const list = (data.auteurs ?? [])
|
||||
.filter((a: any) => a.ingere === true)
|
||||
.map((a: any) => ({ id: String(a.id), nom: String(a.nom), ingere: true }))
|
||||
auteursIngeresCache = list
|
||||
@@ -94,33 +73,26 @@ function loadAuteursIngeres(): AuteurMini[] {
|
||||
}
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event: H3Event) => {
|
||||
const config = useRuntimeConfig(event)
|
||||
|
||||
// 1. Rate limit (20 req/jour/IP, IP hashée RGPD)
|
||||
const ip =
|
||||
getHeader(event, 'x-forwarded-for')?.split(',')[0].trim() ||
|
||||
event.node.req.socket?.remoteAddress ||
|
||||
'0.0.0.0'
|
||||
export default defineEventHandler(async (event) => {
|
||||
const config = useRuntimeConfig()
|
||||
|
||||
const ip = getHeader(event, 'x-forwarded-for')?.split(',')[0].trim()
|
||||
|| event.node.req.socket?.remoteAddress
|
||||
|| '0.0.0.0'
|
||||
const allowed = checkRateLimitJson(ip, 'chatbot-pensees', 20)
|
||||
if (!allowed) {
|
||||
throw createError({ statusCode: 429, message: 'Limite de 20 questions par jour atteinte.' })
|
||||
}
|
||||
if (!allowed) throw createError({ statusCode: 429, message: 'Limite de 20 questions par jour atteinte.' })
|
||||
|
||||
// 2. Body parse + validation
|
||||
const body = await readBody<ChatbotPenseesRequest>(event)
|
||||
const body = await readBody(event)
|
||||
if (!body?.query || body.query.trim().length < 3 || body.query.trim().length > 500) {
|
||||
throw createError({ statusCode: 400, message: 'Query invalide (3-500 caractères).' })
|
||||
}
|
||||
|
||||
const query = body.query.trim()
|
||||
const mode = body.mode || 'hybrid'
|
||||
const corpus = body.corpus || 'both'
|
||||
const ragUrl = (config.ragPeUrl as string) || 'http://localhost:9621'
|
||||
const query: string = body.query.trim()
|
||||
const mode: string = body.mode || 'hybrid'
|
||||
const corpus: string = body.corpus || 'both'
|
||||
const ragUrl: string = (config.ragPeUrl as string) || 'http://localhost:9621'
|
||||
const auteurSlug: string | null = body.auteur_slug?.trim().toLowerCase() || null
|
||||
|
||||
// Validation auteur_slug (Phase 8.E) : match contre la liste des auteurs ingérés
|
||||
const auteurSlug = body.auteur_slug?.trim().toLowerCase() || null
|
||||
let nomAuteurMatch: string | null = null
|
||||
if (auteurSlug) {
|
||||
const ingeres = loadAuteursIngeres()
|
||||
@@ -128,7 +100,6 @@ export default defineEventHandler(async (event: H3Event) => {
|
||||
nomAuteurMatch = auteur?.nom ?? null
|
||||
}
|
||||
|
||||
// Préface adaptative : auteur prioritaire si slug matché, sinon corpus
|
||||
let systemPreface: string
|
||||
if (auteurSlug && nomAuteurMatch) {
|
||||
systemPreface = buildPrefaceAuteur(nomAuteurMatch, auteurSlug)
|
||||
@@ -140,47 +111,34 @@ export default defineEventHandler(async (event: H3Event) => {
|
||||
systemPreface = SYSTEM_PREFACE_BOTH
|
||||
}
|
||||
|
||||
// 3. Health guard — LightRAG down = erreur claire, pas de fallback hallucinatoire
|
||||
try {
|
||||
await $fetch(`${ragUrl}/health`, { timeout: 5000 })
|
||||
} catch {
|
||||
throw createError({
|
||||
statusCode: 503,
|
||||
message: 'RAG indisponible pour l\'instant — réessaie dans quelques minutes.',
|
||||
})
|
||||
throw createError({ statusCode: 503, message: 'RAG indisponible pour l\'instant — réessaie dans quelques minutes.' })
|
||||
}
|
||||
|
||||
// 4. Call LightRAG VPS — préface système injectée dans la query
|
||||
const ragQuery = `${systemPreface}\n\nQuestion : ${query}`
|
||||
|
||||
// Construction du body : hl_keywords + ll_keywords si auteur ciblé
|
||||
// NB : LightRAG ne supporte ni keyword_filter ni ids ni metadata_filter (preflight OpenAPI confirmé).
|
||||
// hl_keywords / ll_keywords sont les seuls leviers natifs de priorisation par auteur.
|
||||
const ragBody: Record<string, unknown> = { query: ragQuery, mode }
|
||||
const ragBody: Record<string, any> = { query: ragQuery, mode }
|
||||
if (auteurSlug && nomAuteurMatch) {
|
||||
ragBody.hl_keywords = [nomAuteurMatch, auteurSlug]
|
||||
ragBody.ll_keywords = [auteurSlug]
|
||||
}
|
||||
|
||||
let ragResponse: LightRAGQueryResponse
|
||||
let ragResponse: any
|
||||
try {
|
||||
ragResponse = await $fetch<LightRAGQueryResponse>(`${ragUrl}/query`, {
|
||||
ragResponse = await $fetch(`${ragUrl}/query`, {
|
||||
method: 'POST',
|
||||
body: ragBody,
|
||||
timeout: 90000,
|
||||
})
|
||||
} catch (e: any) {
|
||||
const status = e?.response?.status
|
||||
if (status === 429) {
|
||||
throw createError({ statusCode: 429, message: 'RAG saturé — réessaie dans quelques instants.' })
|
||||
}
|
||||
if (status === 429) throw createError({ statusCode: 429, message: 'RAG saturé — réessaie dans quelques instants.' })
|
||||
throw createError({ statusCode: 504, message: 'RAG en cours de processing — réessaie dans quelques secondes.' })
|
||||
}
|
||||
|
||||
// Fallback post-process : si auteur ciblé et que les references LightRAG remontent
|
||||
// des chunks hors slug__, on l'indique pour transparence. La préface LLM est la garde principale.
|
||||
let chunksOffTarget = 0
|
||||
let chunksOnTarget = 0
|
||||
let chunksOffTarget = 0
|
||||
if (auteurSlug && nomAuteurMatch && Array.isArray(ragResponse.references)) {
|
||||
const slugPrefix = `${auteurSlug}__`
|
||||
for (const ref of ragResponse.references) {
|
||||
@@ -191,7 +149,6 @@ export default defineEventHandler(async (event: H3Event) => {
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Retour formaté
|
||||
return {
|
||||
response: ragResponse.response ?? '',
|
||||
mode,
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
/**
|
||||
* POST /api/chatbot-reseaux
|
||||
* Chatbot Réseaux AEP — Carte 2 "Réseaux de bifurcation"
|
||||
* Keyword search sur reseaux-bifurcation.json + Mistral Small.
|
||||
* Keyword search sur reseaux-bifurcation.json + LLM via Bifrost (server/utils/bifrost.ts).
|
||||
*/
|
||||
// @ts-ignore — JSON import résolu par Rollup
|
||||
import reseauxData from '../../public/data/reseaux-bifurcation.json'
|
||||
import { checkRateLimitJson } from '~/server/utils/rateLimitJson'
|
||||
import { pickBifrostTier, bifrostContent, REASONING_MARGIN_TOKENS, type BifrostChatResponse } from '~/server/utils/bifrost'
|
||||
|
||||
interface Structure {
|
||||
id: string
|
||||
@@ -61,6 +62,7 @@ export default defineEventHandler(async (event) => {
|
||||
const body = await readBody(event)
|
||||
const question: string = (body?.question ?? '').trim()
|
||||
if (!question || question.length < 5) throw createError({ statusCode: 400, message: 'Question trop courte.' })
|
||||
const tier = pickBifrostTier(body?.mode)
|
||||
|
||||
const structures: Structure[] = ((reseauxData as any).structures ?? [])
|
||||
const keywords = extractKeywords(question)
|
||||
@@ -82,20 +84,22 @@ export default defineEventHandler(async (event) => {
|
||||
|
||||
const systemPrompt = SYSTEM_PROMPT.replace('{{STRUCTURES_JSON}}', JSON.stringify(context, null, 0))
|
||||
|
||||
const nebiusApiKey = config.nebiusApiKey as string
|
||||
if (!nebiusApiKey) throw createError({ statusCode: 500, message: 'Clé API Nebius manquante.' })
|
||||
const bifrostUrl = config.bifrostUrl as string
|
||||
const bifrostVk = config.bifrostVk as string
|
||||
if (!bifrostVk) throw createError({ statusCode: 500, message: 'Clé Bifrost manquante.' })
|
||||
|
||||
let mistralRaw: string
|
||||
try {
|
||||
const res = await $fetch<{ choices: { message: { content: string } }[] }>(
|
||||
'https://api.tokenfactory.nebius.com/v1/chat/completions',
|
||||
const res = await $fetch<BifrostChatResponse>(
|
||||
`${bifrostUrl}/v1/chat/completions`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${nebiusApiKey}`, 'Content-Type': 'application/json' },
|
||||
headers: { 'x-bf-vk': bifrostVk, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model: 'deepseek-ai/DeepSeek-V3.2',
|
||||
model: tier.model,
|
||||
fallbacks: tier.fallbacks,
|
||||
temperature: 0.3,
|
||||
max_tokens: 700,
|
||||
max_tokens: 700 + REASONING_MARGIN_TOKENS,
|
||||
response_format: { type: 'json_object' },
|
||||
messages: [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
@@ -104,7 +108,7 @@ export default defineEventHandler(async (event) => {
|
||||
}),
|
||||
}
|
||||
)
|
||||
mistralRaw = res.choices?.[0]?.message?.content ?? '{}'
|
||||
mistralRaw = bifrostContent(res, 'chatbot-reseaux')
|
||||
} catch {
|
||||
throw createError({ statusCode: 502, message: 'Erreur IA — réessaie dans quelques instants.' })
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
/**
|
||||
* POST /api/chatbot-taff
|
||||
* Chatbot d'aiguillage — Carte 3 "Trouver du taf"
|
||||
* Lit plateformes-taff.json, appelle Mistral Small, retourne recommandations.
|
||||
* Lit plateformes-taff.json, appelle un LLM via Bifrost (server/utils/bifrost.ts), retourne recommandations.
|
||||
*/
|
||||
|
||||
// @ts-ignore — JSON import résolu par Vite/Rollup
|
||||
import taffData from '../../public/data/plateformes-taff.json'
|
||||
import { checkRateLimitJson } from '~/server/utils/rateLimitJson'
|
||||
import { pickBifrostTier, bifrostContent, REASONING_MARGIN_TOKENS, type BifrostChatResponse } from '~/server/utils/bifrost'
|
||||
|
||||
interface PlateformeMinimal {
|
||||
id: string
|
||||
@@ -66,6 +67,7 @@ export default defineEventHandler(async (event) => {
|
||||
if (!question || question.length < 5) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Question trop courte.' })
|
||||
}
|
||||
const tier = pickBifrostTier(body?.mode)
|
||||
|
||||
// Données bundlées statiquement à la compilation (import JSON)
|
||||
const plateformes: PlateformeMinimal[] = ((taffData as any).plateformes ?? []).map((p: any) => ({
|
||||
@@ -91,22 +93,24 @@ export default defineEventHandler(async (event) => {
|
||||
|
||||
const systemPrompt = SYSTEM_PROMPT.replace('{{PLATEFORMES_JSON}}', JSON.stringify(context, null, 0))
|
||||
|
||||
const nebiusApiKey = config.nebiusApiKey as string
|
||||
if (!nebiusApiKey) {
|
||||
throw createError({ statusCode: 500, statusMessage: 'Clé API Nebius manquante.' })
|
||||
const bifrostUrl = config.bifrostUrl as string
|
||||
const bifrostVk = config.bifrostVk as string
|
||||
if (!bifrostVk) {
|
||||
throw createError({ statusCode: 500, statusMessage: 'Clé Bifrost manquante.' })
|
||||
}
|
||||
|
||||
let mistralRaw: string
|
||||
try {
|
||||
const res = await $fetch<{ choices: { message: { content: string } }[] }>(
|
||||
'https://api.tokenfactory.nebius.com/v1/chat/completions',
|
||||
const res = await $fetch<BifrostChatResponse>(
|
||||
`${bifrostUrl}/v1/chat/completions`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${nebiusApiKey}`, 'Content-Type': 'application/json' },
|
||||
headers: { 'x-bf-vk': bifrostVk, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model: 'deepseek-ai/DeepSeek-V3.2',
|
||||
model: tier.model,
|
||||
fallbacks: tier.fallbacks,
|
||||
temperature: 0.3,
|
||||
max_tokens: 700,
|
||||
max_tokens: 700 + REASONING_MARGIN_TOKENS,
|
||||
response_format: { type: 'json_object' },
|
||||
messages: [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
@@ -115,7 +119,7 @@ export default defineEventHandler(async (event) => {
|
||||
}),
|
||||
}
|
||||
)
|
||||
mistralRaw = res.choices?.[0]?.message?.content ?? '{}'
|
||||
mistralRaw = bifrostContent(res, 'chatbot-taff')
|
||||
} catch {
|
||||
throw createError({ statusCode: 502, statusMessage: 'Erreur IA — réessaie dans quelques instants.' })
|
||||
}
|
||||
|
||||
@@ -145,22 +145,19 @@ export default defineEventHandler(async (event) => {
|
||||
|
||||
const systemPrompt = SYSTEM_PROMPT_V2.replace('{{CONTEXTE_RAG}}', contextStr)
|
||||
|
||||
// 7. Nebius DeepSeek-V3.2 - génération réponse
|
||||
const nebiusApiKey = config.nebiusApiKey as string
|
||||
if (!nebiusApiKey) throw createError({ statusCode: 500, statusMessage: 'Clé API Nebius manquante.' })
|
||||
|
||||
// 7. Mistral Small - génération réponse
|
||||
let mistralRaw: string
|
||||
try {
|
||||
const nebiusRes = await $fetch<{
|
||||
const mistralRes = await $fetch<{
|
||||
choices: { message: { content: string } }[]
|
||||
}>('https://api.tokenfactory.nebius.com/v1/chat/completions', {
|
||||
}>('https://api.mistral.ai/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${nebiusApiKey}`,
|
||||
Authorization: `Bearer ${mistralApiKey}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: 'deepseek-ai/DeepSeek-V3.2',
|
||||
model: 'mistral-small-latest',
|
||||
temperature: 0.3,
|
||||
max_tokens: 600,
|
||||
response_format: { type: 'json_object' },
|
||||
@@ -170,10 +167,10 @@ export default defineEventHandler(async (event) => {
|
||||
]
|
||||
})
|
||||
})
|
||||
mistralRaw = nebiusRes.choices?.[0]?.message?.content ?? '{}'
|
||||
mistralRaw = mistralRes.choices?.[0]?.message?.content ?? '{}'
|
||||
} catch (e: any) {
|
||||
console.error('[chatbot-v2] Erreur Nebius DeepSeek :', e?.message ?? e)
|
||||
throw createError({ statusCode: 502, statusMessage: 'Erreur appel Nebius DeepSeek.' })
|
||||
console.error('[chatbot-v2] Erreur Mistral Small :', e?.message ?? e)
|
||||
throw createError({ statusCode: 502, statusMessage: 'Erreur appel Mistral Small.' })
|
||||
}
|
||||
|
||||
// 8. Parse JSON
|
||||
|
||||
+33
-20
@@ -1,14 +1,14 @@
|
||||
/**
|
||||
* POST /api/chatbot
|
||||
*
|
||||
* Chatbot recherche sémantique — Mistral Small
|
||||
* Chatbot recherche sémantique — LLM via Bifrost (tiers et replis : server/utils/bifrost.ts)
|
||||
* Spec : F §7 (endpoint), F §8 (rate limit), E-spec §6 (détails chatbot)
|
||||
*
|
||||
* Flow :
|
||||
* 1. Rate limit : 10 req/IP/jour (JSON fichier, SHA-256)
|
||||
* 2. Circuit breaker : budget 20€/mois
|
||||
* 3. Fetch top-N fiches (keyword match sur nom+description+fonctions)
|
||||
* 4. Appel Mistral Small avec contexte JSON compact
|
||||
* 4. Appel Bifrost (tier RAPIDE par défaut) avec contexte JSON compact
|
||||
* 5. Parse JSON → { reponse_texte, fiches_recommandees }
|
||||
* 6. Log stats_usage
|
||||
*
|
||||
@@ -19,6 +19,7 @@
|
||||
|
||||
import { checkRateLimitJson } from '~/server/utils/rateLimitJson'
|
||||
import { checkBudget, calcCoutMistralSmall } from '~/server/utils/circuitBreaker'
|
||||
import { pickBifrostTier, bifrostContent, REASONING_MARGIN_TOKENS, type BifrostChatResponse } from '~/server/utils/bifrost'
|
||||
|
||||
// ── Types ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -131,18 +132,19 @@ async function logUsage(params: {
|
||||
nocodbUrl: string
|
||||
nocodbToken: string
|
||||
statsTableId: string
|
||||
model: string
|
||||
tokensIn: number
|
||||
tokensOut: number
|
||||
coutEur: number
|
||||
}) {
|
||||
const { nocodbUrl, nocodbToken, statsTableId, tokensIn, tokensOut, coutEur } = params
|
||||
const { nocodbUrl, nocodbToken, statsTableId, model, tokensIn, tokensOut, coutEur } = params
|
||||
const logUrl = `${nocodbUrl}/api/v2/tables/${statsTableId}/records`
|
||||
try {
|
||||
await $fetch(logUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'xc-token': nocodbToken, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model: 'mistral-small-latest',
|
||||
model,
|
||||
endpoint: 'chatbot',
|
||||
tokens_in: tokensIn,
|
||||
tokens_out: tokensOut,
|
||||
@@ -180,6 +182,7 @@ export default defineEventHandler(async (event) => {
|
||||
const body = await readBody(event)
|
||||
const question: string = (body?.question ?? '').trim()
|
||||
const filters: { fonction?: string; echelle?: string } = body?.filters ?? {}
|
||||
const tier = pickBifrostTier(body?.mode)
|
||||
|
||||
if (!question || question.length < 3) {
|
||||
throw createError({
|
||||
@@ -247,34 +250,34 @@ export default defineEventHandler(async (event) => {
|
||||
JSON.stringify(fichesContext, null, 0),
|
||||
)
|
||||
|
||||
// 6. Appel Nebius DeepSeek-V3.2
|
||||
const nebiusApiKey = config.nebiusApiKey as string
|
||||
// 6. Appel Bifrost (gateway LLM — remplace l'appel Mistral direct)
|
||||
const bifrostUrl = config.bifrostUrl as string
|
||||
const bifrostVk = config.bifrostVk as string
|
||||
|
||||
if (!nebiusApiKey) {
|
||||
if (!bifrostVk) {
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: 'Clé API Nebius manquante.',
|
||||
statusMessage: 'Clé Bifrost manquante.',
|
||||
})
|
||||
}
|
||||
|
||||
let mistralRaw: string
|
||||
let tokensIn = 0
|
||||
let tokensOut = 0
|
||||
let realModel = tier.model
|
||||
|
||||
try {
|
||||
const nebiusRes = await $fetch<{
|
||||
choices: { message: { content: string } }[]
|
||||
usage?: { prompt_tokens: number; completion_tokens: number }
|
||||
}>('https://api.tokenfactory.nebius.com/v1/chat/completions', {
|
||||
const bifrostRes = await $fetch<BifrostChatResponse>(`${bifrostUrl}/v1/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${nebiusApiKey}`,
|
||||
'x-bf-vk': bifrostVk,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: 'deepseek-ai/DeepSeek-V3.2',
|
||||
model: tier.model,
|
||||
fallbacks: tier.fallbacks,
|
||||
temperature: 0.3,
|
||||
max_tokens: 600,
|
||||
max_tokens: 600 + REASONING_MARGIN_TOKENS,
|
||||
response_format: { type: 'json_object' },
|
||||
messages: [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
@@ -283,11 +286,16 @@ export default defineEventHandler(async (event) => {
|
||||
}),
|
||||
})
|
||||
|
||||
mistralRaw = nebiusRes.choices?.[0]?.message?.content ?? '{}'
|
||||
tokensIn = nebiusRes.usage?.prompt_tokens ?? 0
|
||||
tokensOut = nebiusRes.usage?.completion_tokens ?? 0
|
||||
mistralRaw = bifrostContent(bifrostRes, 'chatbot')
|
||||
tokensIn = bifrostRes.usage?.prompt_tokens ?? 0
|
||||
tokensOut = bifrostRes.usage?.completion_tokens ?? 0
|
||||
realModel =
|
||||
bifrostRes.extra_fields?.resolved_model_used ??
|
||||
(bifrostRes.extra_fields?.provider
|
||||
? `${bifrostRes.extra_fields.provider}/${tier.model.split('/').slice(1).join('/')}`
|
||||
: tier.model)
|
||||
} catch (e: any) {
|
||||
console.error('[chatbot] Erreur Nebius DeepSeek:', e?.message ?? e)
|
||||
console.error('[chatbot] Erreur Bifrost:', e?.message ?? e)
|
||||
throw createError({
|
||||
statusCode: 502,
|
||||
statusMessage: 'Erreur appel IA — réessaie dans quelques instants.',
|
||||
@@ -317,11 +325,16 @@ export default defineEventHandler(async (event) => {
|
||||
}
|
||||
|
||||
// 8. Log usage (non bloquant)
|
||||
const coutEur = calcCoutMistralSmall(tokensIn, tokensOut)
|
||||
// Coût réel calculable seulement si le provider ayant répondu est Mistral —
|
||||
// les autres tiers Bifrost (Groq, Cerebras, Gemini free tier, Cohere) sont free-tier,
|
||||
// donc cout_eur=0 pour ne pas fausser le circuit breaker avec un tarif Mistral inapplicable.
|
||||
const isMistral = realModel.toLowerCase().startsWith('mistral')
|
||||
const coutEur = isMistral ? calcCoutMistralSmall(tokensIn, tokensOut) : 0
|
||||
logUsage({
|
||||
nocodbUrl: config.nocodbUrl as string,
|
||||
nocodbToken: config.nocodbToken as string,
|
||||
statsTableId,
|
||||
model: realModel,
|
||||
tokensIn,
|
||||
tokensOut,
|
||||
coutEur,
|
||||
|
||||
+187
-89
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* POST /api/submit
|
||||
*
|
||||
* Soumission d'une nouvelle organisation par un utilisateur.
|
||||
* - Validation Zod côté serveur
|
||||
* Soumission multi-type (ecosysteme / reseau / job / outil).
|
||||
* - Validation Zod côté serveur (champs communs + par type)
|
||||
* - Rate limit Redis : 3 soumissions / IP / jour
|
||||
* - Géocodage Nominatim (optionnel — fallback silencieux)
|
||||
* - INSERT NocoDB : moderation_status=pending, ai_processed=false
|
||||
@@ -14,63 +14,52 @@
|
||||
|
||||
import { z } from 'zod'
|
||||
import { checkRateLimit } from '~/server/utils/rateLimit'
|
||||
import { notifyNtfy, buildSubmissionNotif } from '~/server/utils/notify'
|
||||
import { LibreSubmitSchema, toOrgaPayload, toReferencePayload, type LibreSubmitInput } from '~/utils/submitLibre'
|
||||
|
||||
// ── Schéma Zod ────────────────────────────────────────────────────────────────
|
||||
const SUBMISSION_TYPES = ['ecosysteme', 'reseau', 'job', 'outil'] as const
|
||||
|
||||
const FONCTIONS = [
|
||||
'Juridique',
|
||||
'Technique',
|
||||
'Économique',
|
||||
'Administratif',
|
||||
'Chantier',
|
||||
'Comptabilité',
|
||||
'Développement',
|
||||
'Formation',
|
||||
'Gestion d\'agence',
|
||||
'Santé mentale',
|
||||
'Juridique', 'Technique', 'Économique', 'Administratif', 'Chantier',
|
||||
'Comptabilité', 'Développement', 'Formation', 'Gestion d\'agence', 'Santé mentale',
|
||||
] as const
|
||||
|
||||
const ECHELLES = ['National', 'Régional', 'Local'] as const
|
||||
|
||||
const TERRITOIRES = [
|
||||
'Métropole',
|
||||
'Guadeloupe',
|
||||
'Martinique',
|
||||
'Guyane',
|
||||
'La Réunion',
|
||||
'Mayotte',
|
||||
'Métropole', 'Guadeloupe', 'Martinique', 'Guyane', 'La Réunion', 'Mayotte',
|
||||
] as const
|
||||
|
||||
const TYPES_PRINCIPAUX = ['agence', 'collectif', 'reseau', 'asso', 'ressource'] as const
|
||||
|
||||
const TYPES_PLATEFORME = ['mise-en-relation', 'appel-offre-public', 'communaute-pro'] as const
|
||||
|
||||
const CATEGORIES_OUTIL = ['simulateur', 'logiciel', 'guide', 'autre'] as const
|
||||
|
||||
export const SubmitSchema = z.object({
|
||||
// Champs communs
|
||||
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(''))
|
||||
.transform((v) => v || undefined),
|
||||
description_user: z
|
||||
.string()
|
||||
.min(50, 'Minimum 50 caractères')
|
||||
.max(500, 'Maximum 500 caractères')
|
||||
.trim(),
|
||||
echelle: z.enum(ECHELLES, { errorMap: () => ({ message: 'Échelle invalide' }) }),
|
||||
fonctions: z
|
||||
.array(z.enum(FONCTIONS))
|
||||
.min(1, 'Sélectionne au moins une fonction')
|
||||
.max(5, 'Maximum 5 fonctions'),
|
||||
territoire: z.enum(TERRITOIRES, { errorMap: () => ({ message: 'Territoire invalide' }) }),
|
||||
localisation_ville: z
|
||||
.string()
|
||||
.max(100)
|
||||
.optional()
|
||||
.transform((v) => v?.trim() || undefined),
|
||||
submitted_by_email: z
|
||||
.string()
|
||||
.email('Email invalide')
|
||||
.optional()
|
||||
.or(z.literal(''))
|
||||
.transform((v) => v || undefined),
|
||||
url: z.string().url('URL invalide').optional().or(z.literal('')).transform(v => v || undefined),
|
||||
description_user: z.string().min(50, 'Minimum 50 caractères').max(500, 'Maximum 500 caractères').trim(),
|
||||
submitted_by_email: z.string().email('Email invalide').optional().or(z.literal('')).transform(v => v || undefined),
|
||||
submission_type: z.enum(SUBMISSION_TYPES, { errorMap: () => ({ message: 'Type de soumission invalide' }) }),
|
||||
|
||||
// Champs écosystème
|
||||
echelle: z.enum(ECHELLES).optional(),
|
||||
fonctions: z.array(z.enum(FONCTIONS)).min(1).max(5).optional(),
|
||||
territoire: z.enum(TERRITOIRES).optional(),
|
||||
localisation_ville: z.string().max(100).optional().transform(v => v?.trim() || undefined),
|
||||
|
||||
// Champs réseau
|
||||
type_principal: z.enum(TYPES_PRINCIPAUX).optional(),
|
||||
projet_lien: z.string().url('URL invalide').optional().or(z.literal('')),
|
||||
projet_adresse: z.string().max(200).optional().transform(v => v?.trim() || undefined),
|
||||
|
||||
// Champs job
|
||||
type_plateforme: z.enum(TYPES_PLATEFORME).optional(),
|
||||
|
||||
// Champs outil
|
||||
categorie: z.enum(CATEGORIES_OUTIL).optional(),
|
||||
})
|
||||
|
||||
export type SubmitInput = z.infer<typeof SubmitSchema>
|
||||
@@ -116,6 +105,15 @@ export default defineEventHandler(async (event) => {
|
||||
|
||||
// 3. Lire et valider le body
|
||||
const body = await readBody(event)
|
||||
|
||||
// Formulaire assoupli (B5-M1) — liens + texte libre, type suggéré optionnel.
|
||||
if (body?.mode === 'libre') {
|
||||
return handleLibreSubmit(body)
|
||||
}
|
||||
|
||||
// (rate limit déjà vérifié ci-dessus pour les deux modes)
|
||||
|
||||
// Mode détaillé (5 formulaires typés, conservés derrière un lien discret)
|
||||
const parsed = SubmitSchema.safeParse(body)
|
||||
|
||||
if (!parsed.success) {
|
||||
@@ -148,22 +146,36 @@ export default defineEventHandler(async (event) => {
|
||||
const nocoBaseId = config.nocodbBase
|
||||
|
||||
const payload: Record<string, unknown> = {
|
||||
submission_type: data.submission_type,
|
||||
nom: data.nom,
|
||||
url: data.url || null,
|
||||
description_user: data.description_user,
|
||||
echelle: data.echelle,
|
||||
tags_fonction: data.fonctions.join(','),
|
||||
territoire: data.territoire,
|
||||
localisation_ville: data.localisation_ville || null,
|
||||
submitted_by_email: data.submitted_by_email || null,
|
||||
moderation_status: 'pending',
|
||||
scrape_status: data.url ? 'pending' : 'no_link',
|
||||
ai_processed: false,
|
||||
submitted_at: new Date().toISOString(),
|
||||
latitude,
|
||||
longitude,
|
||||
}
|
||||
|
||||
// Champs spécifiques selon le type
|
||||
if (data.submission_type === 'ecosysteme') {
|
||||
payload.echelle = data.echelle
|
||||
payload.tags_fonction = data.fonctions?.join(',')
|
||||
payload.territoire = data.territoire
|
||||
payload.localisation_ville = data.localisation_ville || null
|
||||
payload.scrape_status = data.url ? 'pending' : 'no_link'
|
||||
} else if (data.submission_type === 'reseau') {
|
||||
payload.type_principal = data.type_principal
|
||||
payload.projet_lien = data.projet_lien || null
|
||||
payload.projet_adresse = data.projet_adresse || null
|
||||
payload.localisation_ville = data.localisation_ville || null
|
||||
} else if (data.submission_type === 'job') {
|
||||
payload.type_plateforme = data.type_plateforme
|
||||
} else if (data.submission_type === 'outil') {
|
||||
payload.categorie = data.categorie
|
||||
}
|
||||
|
||||
// NocoDB v1 endpoint
|
||||
const insertUrl = `${nocodbUrl}/api/v1/db/data/noco/${nocoBaseId}/${orgTableId}`
|
||||
|
||||
@@ -187,10 +199,17 @@ export default defineEventHandler(async (event) => {
|
||||
|
||||
const submissionId = insertedRecord?.Id ?? insertedRecord?.id ?? null
|
||||
|
||||
// 6. Notification email Jules (fire-and-forget — n'impacte pas la réponse)
|
||||
notifyJules(config, data, submissionId).catch((e) =>
|
||||
console.warn('[submit] Email notification échouée:', e?.message ?? e),
|
||||
)
|
||||
// 6. Notification ntfy à Jules (fire-and-forget — n'impacte pas la réponse)
|
||||
notifyNtfy(
|
||||
process.env.NTFY_TOPIC,
|
||||
buildSubmissionNotif({
|
||||
submissionId,
|
||||
nomSuggere: data.nom,
|
||||
typeSuggere: data.submission_type,
|
||||
nbLiens: data.url ? 1 : 0,
|
||||
nocodbAdminUrl: NOCODB_ADMIN_URL,
|
||||
}),
|
||||
).catch((e) => console.warn('[submit] Notification ntfy échouée:', e?.message ?? e))
|
||||
|
||||
return {
|
||||
status: 201,
|
||||
@@ -202,40 +221,119 @@ export default defineEventHandler(async (event) => {
|
||||
}
|
||||
})
|
||||
|
||||
async function notifyJules(
|
||||
config: ReturnType<typeof useRuntimeConfig>,
|
||||
data: SubmitInput,
|
||||
submissionId: number | null,
|
||||
): Promise<void> {
|
||||
if (!config.resendApiKey) return
|
||||
// ── Formulaire assoupli (B5-M1) ─────────────────────────────────────────────
|
||||
|
||||
const nocoAdminUrl = `http://localhost:8070`
|
||||
const body = {
|
||||
from: 'AEP Carte <contact@trans-former.fr>',
|
||||
to: [config.emailJules],
|
||||
subject: `[AEP] Nouvelle fiche soumise : ${data.nom}`,
|
||||
html: `
|
||||
<p><strong>Nouvelle fiche en attente de modération.</strong></p>
|
||||
<ul>
|
||||
<li><strong>Nom :</strong> ${data.nom}</li>
|
||||
<li><strong>URL :</strong> ${data.url ?? '—'}</li>
|
||||
<li><strong>Échelle :</strong> ${data.echelle}</li>
|
||||
<li><strong>Territoire :</strong> ${data.territoire}</li>
|
||||
<li><strong>Fonctions :</strong> ${data.fonctions.join(', ')}</li>
|
||||
<li><strong>Description :</strong> ${data.description_user}</li>
|
||||
${data.submitted_by_email ? `<li><strong>Email soumetteur :</strong> ${data.submitted_by_email}</li>` : ''}
|
||||
${submissionId ? `<li><strong>ID NocoDB :</strong> ${submissionId}</li>` : ''}
|
||||
</ul>
|
||||
<p><a href="${nocoAdminUrl}">Ouvrir NocoDB pour valider</a></p>
|
||||
`,
|
||||
async function handleLibreSubmit(body: unknown) {
|
||||
const parsed = LibreSubmitSchema.safeParse(body)
|
||||
|
||||
if (!parsed.success) {
|
||||
throw createError({
|
||||
statusCode: 422,
|
||||
statusMessage: 'Validation échouée',
|
||||
data: parsed.error.flatten().fieldErrors,
|
||||
})
|
||||
}
|
||||
|
||||
await $fetch('https://api.resend.com/emails', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${config.resendApiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
const data = parsed.data
|
||||
|
||||
// Honeypot : un bot remplit ce champ caché — on répond succès sans rien écrire.
|
||||
if (data.site_web) {
|
||||
return {
|
||||
status: 201,
|
||||
submissionId: null,
|
||||
message: 'Ta fiche est en cours de traitement.',
|
||||
trackingUrl: null,
|
||||
}
|
||||
}
|
||||
|
||||
const config = useRuntimeConfig()
|
||||
|
||||
// Chip « Références » → table ressources_references, pas la table orgas.
|
||||
if (data.type_suggere === 'references') {
|
||||
return insertReferenceLibre(config, data)
|
||||
}
|
||||
|
||||
const payload = toOrgaPayload(data)
|
||||
const insertUrl = `${config.nocodbUrl}/api/v1/db/data/noco/${config.nocodbBase}/${config.orgTableId}`
|
||||
|
||||
let insertedRecord: any
|
||||
try {
|
||||
insertedRecord = await $fetch(insertUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'xc-token': config.nocodbToken, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
} catch (e: any) {
|
||||
console.error('[submit] Erreur NocoDB insert (libre):', e?.message ?? e)
|
||||
throw createError({ statusCode: 502, statusMessage: 'Erreur serveur — réessaie dans quelques instants.' })
|
||||
}
|
||||
|
||||
const submissionId = insertedRecord?.Id ?? insertedRecord?.id ?? null
|
||||
|
||||
notifyNtfy(
|
||||
process.env.NTFY_TOPIC,
|
||||
buildSubmissionNotif({
|
||||
submissionId,
|
||||
nomSuggere: nomPourNotif(payload.nom, data),
|
||||
typeSuggere: payload.submission_type,
|
||||
nbLiens: data.liens.length,
|
||||
nocodbAdminUrl: NOCODB_ADMIN_URL,
|
||||
}),
|
||||
).catch((e) => console.warn('[submit] Notification ntfy échouée (libre):', e?.message ?? e))
|
||||
|
||||
return {
|
||||
status: 201,
|
||||
submissionId,
|
||||
message: 'Ta fiche est en cours de traitement.',
|
||||
trackingUrl: null,
|
||||
}
|
||||
}
|
||||
|
||||
/** URL de modération cliquable depuis la notification (téléphone) — jamais localhost. */
|
||||
const NOCODB_ADMIN_URL = 'https://sheets.trans-former.fr'
|
||||
|
||||
/**
|
||||
* Sans lien, `nomSuggere()` retombe sur les 60 premiers caractères du texte libre :
|
||||
* ce texte ne doit jamais partir vers ntfy (topic public par URL). On ne transmet
|
||||
* le nom que lorsqu'il est dérivé d'un hostname.
|
||||
*/
|
||||
function nomPourNotif(nom: string, data: LibreSubmitInput): string {
|
||||
return data.liens.length > 0 ? nom : '[à qualifier] texte seul (voir NocoDB)'
|
||||
}
|
||||
|
||||
async function insertReferenceLibre(config: ReturnType<typeof useRuntimeConfig>, data: LibreSubmitInput) {
|
||||
const payload = toReferencePayload(data)
|
||||
const insertUrl = `${config.nocodbUrl}/api/v1/db/data/noco/${config.nocodbBase}/${config.referencesTableId}`
|
||||
|
||||
let insertedRecord: any
|
||||
try {
|
||||
insertedRecord = await $fetch(insertUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'xc-token': config.nocodbToken, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
} catch (e: any) {
|
||||
console.error('[submit] Erreur NocoDB insert (référence libre):', e?.message ?? e)
|
||||
throw createError({ statusCode: 502, statusMessage: 'Erreur serveur — réessaie dans quelques instants.' })
|
||||
}
|
||||
|
||||
const referenceId = insertedRecord?.Id ?? insertedRecord?.id ?? null
|
||||
|
||||
notifyNtfy(
|
||||
process.env.NTFY_TOPIC,
|
||||
buildSubmissionNotif({
|
||||
submissionId: referenceId,
|
||||
nomSuggere: nomPourNotif(payload.titre, data),
|
||||
typeSuggere: 'references',
|
||||
nbLiens: data.liens.length,
|
||||
nocodbAdminUrl: NOCODB_ADMIN_URL,
|
||||
}),
|
||||
).catch((e) => console.warn('[submit] Notification ntfy échouée (référence libre):', e?.message ?? e))
|
||||
|
||||
return {
|
||||
status: 201,
|
||||
submissionId: referenceId,
|
||||
message: 'Ta référence est en attente de modération.',
|
||||
trackingUrl: null,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { z } from 'zod'
|
||||
import { checkRateLimit } from '~/server/utils/rateLimit'
|
||||
|
||||
const ReferenceSchema = 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('')).transform(v => v || undefined),
|
||||
description: z.string().min(50, 'Minimum 50 caractères').max(1000, 'Maximum 1000 caractères').trim(),
|
||||
pertinence_rag: z.string().max(500, 'Maximum 500 caractères').optional().transform(v => v?.trim() || undefined),
|
||||
hashtags: z.string().max(300, 'Maximum 300 caractères').optional().transform(v => v?.trim() || undefined),
|
||||
submitted_by_email: z.string().email('Email invalide').optional().or(z.literal('')).transform(v => v || undefined),
|
||||
})
|
||||
|
||||
export type ReferenceInput = z.infer<typeof ReferenceSchema>
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const ip = getHeader(event, 'x-forwarded-for')?.split(',')[0].trim() || event.node.req.socket?.remoteAddress || '0.0.0.0'
|
||||
|
||||
const allowed = await checkRateLimit(ip, 'submit-reference', 3)
|
||||
if (!allowed) {
|
||||
throw createError({ statusCode: 429, statusMessage: 'Trop de soumissions. Réessaie demain.' })
|
||||
}
|
||||
|
||||
const body = await readBody(event)
|
||||
const parsed = ReferenceSchema.safeParse(body)
|
||||
|
||||
if (!parsed.success) {
|
||||
throw createError({
|
||||
statusCode: 422,
|
||||
statusMessage: 'Validation échouée',
|
||||
data: parsed.error.flatten().fieldErrors,
|
||||
})
|
||||
}
|
||||
|
||||
const data = parsed.data
|
||||
|
||||
const payload: Record<string, unknown> = {
|
||||
titre: data.titre,
|
||||
auteur: data.auteur,
|
||||
url: data.url || null,
|
||||
description: data.description,
|
||||
pertinence_rag: data.pertinence_rag || null,
|
||||
hashtags: data.hashtags ? data.hashtags.split(',').map(h => h.trim()).filter(Boolean).join(',') : null,
|
||||
submitted_by_email: data.submitted_by_email || null,
|
||||
moderation_status: 'pending',
|
||||
submitted_at: new Date().toISOString(),
|
||||
}
|
||||
|
||||
const config = useRuntimeConfig()
|
||||
const insertUrl = `${config.nocodbUrl}/api/v1/db/data/noco/${config.nocodbBase}/${config.referencesTableId}`
|
||||
|
||||
let insertedRecord: any
|
||||
try {
|
||||
insertedRecord = await $fetch(insertUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'xc-token': config.nocodbToken,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
} catch (e: any) {
|
||||
console.error('[reference] Erreur NocoDB insert:', e?.message ?? e)
|
||||
throw createError({ statusCode: 502, statusMessage: 'Erreur serveur — réessaie dans quelques instants.' })
|
||||
}
|
||||
|
||||
const referenceId = insertedRecord?.Id ?? insertedRecord?.id ?? null
|
||||
|
||||
return {
|
||||
status: 201,
|
||||
referenceId,
|
||||
message: 'Ta référence est en attente de modération.',
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,10 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
export default defineEventHandler((event) => {
|
||||
const path = join(process.cwd(), 'public', 'data', 'auteurs-pensees.json')
|
||||
const raw = readFileSync(path, 'utf-8')
|
||||
setResponseHeader(event, 'content-type', 'application/json; charset=utf-8')
|
||||
setResponseHeader(event, 'cache-control', 'public, max-age=300')
|
||||
return raw
|
||||
})
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Bifrost — gateway LLM (remplace les appels directs Mistral)
|
||||
* Endpoint OpenAI-compatible : POST {bifrostUrl}/v1/chat/completions
|
||||
* Auth : header x-bf-vk
|
||||
*
|
||||
* 2 tiers (Mission M3 du build Bifrost ; modèles primaires changés le 28/09, session AEP front 4) :
|
||||
* RAPIDE — défaut, pas de toggle UI mode rapide/approfondi sur le site actuellement
|
||||
* APPROFONDI — activable via body.mode === 'approfondi' (prêt pour un futur toggle front)
|
||||
*
|
||||
* ⚠ 28/09 : Groq a retiré groq/llama-3.1-8b-instant (RAPIDE) et groq/llama-3.3-70b-versatile
|
||||
* (APPROFONDI) — 404 model_not_found via Bifrost. Depuis le 23/09 environ, chaque appel
|
||||
* chatbot tombait sur le premier repli Gemini (stats_usage). Primaires passés sur
|
||||
* groq/openai/gpt-oss-20b et groq/openai/gpt-oss-120b ; replis inchangés. Le worker
|
||||
* /opt/aep-worker tourne sur gpt-oss-20b en prod depuis le 28/09 (WORKER_MODEL, json_object) :
|
||||
* Bifrost accepte ce nom de modèle à slash côté Groq.
|
||||
* ⚠ gpt-oss est un modèle à raisonnement :
|
||||
* - la réponse porte un champ message.reasoning à côté de message.content. Les routes
|
||||
* chatbot ne lisent que content (JSON), usage et extra_fields : le champ est ignoré ;
|
||||
* - les tokens de raisonnement sont décomptés de max_tokens. Sans marge, le JSON de la
|
||||
* réponse peut être tronqué ; rendu en HTTP 200, Bifrost ne bascule pas, JSON.parse
|
||||
* échoue → « Je n'ai pas pu analyser ta demande ». Déjà vu le 15/07 : gpt-oss-120b (via Cerebras), 3 appels
|
||||
* corrects sur 4, « tronque parfois à max_tokens 700 ». D'où REASONING_MARGIN_TOKENS, ajouté par
|
||||
* chaque route à son budget de contenu.
|
||||
* ⚠ openrouter-oai exclu (bug Bifrost confirmé — 404 HTML sur modèles avec slash)
|
||||
* ⚠ gemini-oai exige le préfixe "models/" (sinon 403 silencieux)
|
||||
* ⚠ cerebras/gemma-4-31b RETIRÉ du tier RAPIDE (M4, 15/07) : en JSON mode avec un contexte
|
||||
* réel (prompt + ~20 fiches), le modèle part en boucle de répétition dégénérée ("1 1 1...")
|
||||
* dans ~75% des cas où il sert de fallback, produit un JSON invalide, et Bifrost ne bascule
|
||||
* PAS plus loin dans la chaîne (HTTP 200 côté provider = pas une erreur pour Bifrost). Bug
|
||||
* constaté en prod sur chatbot-reseaux (1 échec/3), reproduit 3/4 sur appel direct Bifrost.
|
||||
* Cerebras reste configuré dans Bifrost (dispo pour tier CODE ou reconfiguration future),
|
||||
* juste plus dans cette chaîne tant que ce n'est pas fiabilisé.
|
||||
*/
|
||||
|
||||
export const BIFROST_TIER_RAPIDE = {
|
||||
model: 'groq/openai/gpt-oss-20b',
|
||||
fallbacks: [
|
||||
'gemini-oai/models/gemini-2.5-flash-lite',
|
||||
'cohere/command-r-08-2024',
|
||||
],
|
||||
}
|
||||
|
||||
export const BIFROST_TIER_APPROFONDI = {
|
||||
model: 'groq/openai/gpt-oss-120b',
|
||||
fallbacks: [
|
||||
'gemini-oai/models/gemini-2.5-flash',
|
||||
'mistral/mistral-large-latest',
|
||||
'cohere/command-r-plus-08-2024',
|
||||
],
|
||||
}
|
||||
|
||||
/**
|
||||
* Tokens ajoutés au budget de contenu de chaque route (max_tokens = contenu + marge) pour
|
||||
* absorber le raisonnement de gpt-oss. Plafond seulement : les replis sans raisonnement
|
||||
* n'écrivent pas plus long, les prompts bornent la réponse (200-250 mots). Rester modeste :
|
||||
* la limite de tokens par minute d'une clé Groq peut compter max_tokens avec le prompt.
|
||||
*/
|
||||
export const REASONING_MARGIN_TOKENS = 800
|
||||
|
||||
/** Sélectionne le tier selon le param optionnel body.mode. */
|
||||
export function pickBifrostTier(mode?: string) {
|
||||
return mode === 'approfondi' ? BIFROST_TIER_APPROFONDI : BIFROST_TIER_RAPIDE
|
||||
}
|
||||
|
||||
export interface BifrostChatResponse {
|
||||
/** reasoning : présent avec gpt-oss, jamais lu (la réponse utile est dans content). */
|
||||
choices: { message: { content: string; reasoning?: string }; finish_reason?: string }[]
|
||||
usage?: { prompt_tokens: number; completion_tokens: number }
|
||||
extra_fields?: { provider?: string; resolved_model_used?: string }
|
||||
}
|
||||
|
||||
/**
|
||||
* Contenu JSON de la réponse. Une troncature (finish_reason 'length') ne lève aucune erreur
|
||||
* et finit en message générique côté usager : on la signale au journal pour la voir.
|
||||
*/
|
||||
export function bifrostContent(res: BifrostChatResponse, route: string): string {
|
||||
const choice = res.choices?.[0]
|
||||
if (choice?.finish_reason === 'length') {
|
||||
console.warn(`[${route}] réponse tronquée (finish_reason=length) : relever REASONING_MARGIN_TOKENS`)
|
||||
}
|
||||
return choice?.message?.content ?? '{}'
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Notification ntfy.sh — remplace Resend (abandonné le 15/07, domaine non vérifié).
|
||||
* POST https://ntfy.sh/${NTFY_TOPIC}
|
||||
*
|
||||
* RÈGLE : un topic ntfy est une URL, pas un coffre — le message ne contient
|
||||
* JAMAIS l'email du contributeur ni le texte libre soumis. Seulement des
|
||||
* métadonnées de modération (id NocoDB, nom suggéré, type, nb de liens).
|
||||
*
|
||||
* Si NTFY_TOPIC est absent : log et continue (pas d'erreur bloquante).
|
||||
*/
|
||||
|
||||
export interface NtfyNotifyInput {
|
||||
title: string
|
||||
message: string
|
||||
/** Priorité ntfy 1-5 (3 = défaut) */
|
||||
priority?: number
|
||||
tags?: string[]
|
||||
/** Lien cliqué depuis la notif (ex : NocoDB) */
|
||||
clickUrl?: string
|
||||
}
|
||||
|
||||
export async function notifyNtfy(topic: string | undefined, input: NtfyNotifyInput): Promise<void> {
|
||||
if (!topic) {
|
||||
console.warn('[notify] NTFY_TOPIC absent — notification skippée:', input.title)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const headers: Record<string, string> = {
|
||||
Title: encodeHeaderValue(input.title),
|
||||
Priority: String(input.priority ?? 3),
|
||||
}
|
||||
if (input.tags?.length) headers.Tags = input.tags.join(',')
|
||||
if (input.clickUrl) headers.Click = input.clickUrl
|
||||
|
||||
const res = await fetch(`https://ntfy.sh/${topic}`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: input.message,
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
console.warn('[notify] ntfy erreur HTTP:', res.status, await res.text())
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.warn('[notify] ntfy exception:', e?.message ?? e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Construit le message de notification pour une nouvelle soumission.
|
||||
* N'inclut jamais l'email ni le texte libre du contributeur.
|
||||
*/
|
||||
export function buildSubmissionNotif(input: {
|
||||
submissionId: number | string | null
|
||||
nomSuggere: string
|
||||
typeSuggere: string
|
||||
nbLiens: number
|
||||
nocodbAdminUrl?: string
|
||||
}): NtfyNotifyInput {
|
||||
const { submissionId, nomSuggere, typeSuggere, nbLiens, nocodbAdminUrl } = input
|
||||
return {
|
||||
title: '[AEP] Nouvelle fiche soumise',
|
||||
message: [
|
||||
`Id NocoDB : ${submissionId ?? '—'}`,
|
||||
`Nom suggéré : ${nomSuggere}`,
|
||||
`Type suggéré : ${typeSuggere}`,
|
||||
`Liens : ${nbLiens}`,
|
||||
].join('\n'),
|
||||
tags: ['inbox_tray'],
|
||||
clickUrl: nocodbAdminUrl,
|
||||
}
|
||||
}
|
||||
|
||||
function encodeHeaderValue(v: string): string {
|
||||
// ntfy accepte l'UTF-8 dans les headers mais certains proxies sont capricieux
|
||||
// avec les accents en header brut — on reste simple, ntfy gère nativement.
|
||||
return v
|
||||
}
|
||||
+11
-9
@@ -11,16 +11,18 @@ export default {
|
||||
extend: {
|
||||
colors: {
|
||||
/* Palette NAV V2 — Sobre institutionnel */
|
||||
/* Pointent vers les variables CSS --nav-* (alias des rôles @transformer/ui,
|
||||
voir assets/css/main.css) pour rester cohérentes clair/sombre. (B3-M0) */
|
||||
nav: {
|
||||
bg: '#f8f6f1',
|
||||
'bg-alt':'#eee9df',
|
||||
surface: '#ffffff',
|
||||
primary: 'rgba(26, 34, 56, 0.6)',
|
||||
'primary-solid': '#1a2238',
|
||||
accent: '#f5b342',
|
||||
text: '#1a2238',
|
||||
'text-muted': 'rgba(26, 34, 56, 0.55)',
|
||||
'text-on-primary': '#f8f6f1',
|
||||
bg: 'var(--nav-bg)',
|
||||
'bg-alt':'var(--nav-bg-alt)',
|
||||
surface: 'var(--nav-surface)',
|
||||
primary: 'var(--nav-primary)',
|
||||
'primary-solid': 'var(--nav-primary-solid)',
|
||||
accent: 'var(--nav-accent)',
|
||||
text: 'var(--nav-text)',
|
||||
'text-muted': 'var(--nav-text-muted)',
|
||||
'text-on-primary': 'var(--nav-text-on-primary)',
|
||||
},
|
||||
/* Héritage V1 — gardé pour composants existants */
|
||||
sage: {
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Les trois échelles du deck du 23/09/2026 (conférence AEP), slides 15 à 18.
|
||||
* Texte repris mot pour mot : c'est la tête de chaque onglet (directive Jules du 25/09, point 2).
|
||||
* Source unique : la future nav S / M / XL (arbitrage n° 1, table R) lira aussi ce fichier.
|
||||
*/
|
||||
export type EchelleId = 'S' | 'M' | 'XL'
|
||||
|
||||
export interface Echelle {
|
||||
id: EchelleId
|
||||
titre: string
|
||||
sousTitre?: string
|
||||
mots: string[]
|
||||
phrase: string
|
||||
/** Catégorie de /proposer présélectionnée par le lien de contribution. */
|
||||
categorieProposer: 'ecosysteme' | 'reseau' | 'jobs' | 'outils' | 'references'
|
||||
}
|
||||
|
||||
export const ECHELLES: Record<EchelleId, Echelle> = {
|
||||
S: {
|
||||
id: 'S',
|
||||
titre: 'Partir de soi',
|
||||
sousTitre: "pour s'émanciper",
|
||||
mots: [
|
||||
'quête de sens',
|
||||
'où habiter, avec qui',
|
||||
'gagner sa vie',
|
||||
'santé mentale',
|
||||
'mener ses projets',
|
||||
'naviguer avec soi',
|
||||
'les compétences psychosociales',
|
||||
],
|
||||
phrase: 'Des ressources, des méthodes, des pairs : matériel et mutualisé.',
|
||||
categorieProposer: 'outils',
|
||||
},
|
||||
M: {
|
||||
id: 'M',
|
||||
titre: 'La communauté',
|
||||
mots: [
|
||||
'entraide',
|
||||
'mutualisation',
|
||||
'appartenance',
|
||||
'être vu, soutenu',
|
||||
'traverser les creux',
|
||||
'accomplissement',
|
||||
'le codev',
|
||||
],
|
||||
phrase: "C'est le groupe qui remet un individu debout. L'affinité fait tenir, la confrontation fait avancer.",
|
||||
categorieProposer: 'ecosysteme',
|
||||
},
|
||||
XL: {
|
||||
id: 'XL',
|
||||
titre: 'Contribuer',
|
||||
mots: [
|
||||
'contribution',
|
||||
'affiliation, filiation',
|
||||
'identité',
|
||||
'sens',
|
||||
'de nouveaux récits',
|
||||
'un nouveau contrat social',
|
||||
'la médecine du corps social',
|
||||
],
|
||||
phrase: 'La bibliothèque, les récits, la filiation entre projets.',
|
||||
categorieProposer: 'references',
|
||||
},
|
||||
}
|
||||
|
||||
/** Slide 15, commune aux trois échelles. */
|
||||
export const ECHELLES_CADRE = "Trois échelles, imbriquées. Pas un escalier : ça circule dans les deux sens. Entrez par celle qui presse."
|
||||
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* B5-M1 — Schéma + mapping du formulaire « proposer » assoupli.
|
||||
*
|
||||
* LE PRINCIPE (décision Jules) : le contributeur colle un lien (ou une liste
|
||||
* de liens) + explique en texte libre pourquoi c'est pertinent. Pas de
|
||||
* description obligatoire, pas de limite de mots, pas de template imposé.
|
||||
* Le typage devient une suggestion optionnelle (chips).
|
||||
*
|
||||
* Partagé client (formulaire, validation immédiate) et serveur (validation
|
||||
* finale + mapping vers les colonnes NocoDB existantes — aucun changement de
|
||||
* schéma, cf. B5-proposer-pipe.md écart #4).
|
||||
*/
|
||||
|
||||
import { z } from 'zod'
|
||||
|
||||
/** Chips optionnelles — mêmes 5 catégories que les onglets historiques de /proposer. */
|
||||
export const TYPES_SUGGERES = ['ecosysteme', 'reseau', 'jobs', 'outils', 'references'] as const
|
||||
export type TypeSuggere = (typeof TYPES_SUGGERES)[number]
|
||||
|
||||
export const TYPES_SUGGERES_LABELS: Record<TypeSuggere, string> = {
|
||||
ecosysteme: 'Écosystème / Entraide',
|
||||
reseau: 'Réseau AEP',
|
||||
jobs: 'Jobs',
|
||||
outils: 'Outils',
|
||||
references: 'Références',
|
||||
}
|
||||
|
||||
/**
|
||||
* Légende courte sous les chips — définitions tirées des textes existants du site :
|
||||
* ecosysteme/jobs/outils/references reprennent la tête d'onglet (`TABS`, pages/proposer.vue)
|
||||
* et/ou la phrase de l'échelle qui pointe vers ce type (utils/echelles.ts) ; reseau reprend
|
||||
* la tête d'onglet « Réseau AEP » et les champs projet/lien de FormReseau.vue.
|
||||
*/
|
||||
export const TYPES_SUGGERES_HINTS: Record<TypeSuggere, string> = {
|
||||
ecosysteme: 'entraide, mutualisation',
|
||||
reseau: 'acteurs et projets AEP',
|
||||
jobs: 'offres, plateformes d\'emploi',
|
||||
outils: 'ressources, méthodes',
|
||||
references: 'bibliothèque, ouvrages',
|
||||
}
|
||||
|
||||
/** URL http(s) uniquement — un `ftp://` ou autre schéma est invalide. */
|
||||
const httpUrl = z
|
||||
.string()
|
||||
.trim()
|
||||
.refine((v) => /^https?:\/\/.+/i.test(v), { message: 'URL invalide (doit commencer par http:// ou https://)' })
|
||||
|
||||
export const LibreSubmitSchema = z
|
||||
.object({
|
||||
liens: z.array(httpUrl).max(10, 'Maximum 10 liens').default([]),
|
||||
texte: z.string().max(8000, 'Maximum 8000 caractères').default(''),
|
||||
submitted_by_email: z
|
||||
.string()
|
||||
.email('Email invalide')
|
||||
.optional()
|
||||
.or(z.literal(''))
|
||||
.transform((v) => v || undefined),
|
||||
type_suggere: z.enum(TYPES_SUGGERES).optional().or(z.literal('')).transform((v) => (v ? v : undefined)),
|
||||
// Deuxième chip optionnelle (B5-M2b) — indécis entre deux types.
|
||||
type_secondaire: z.enum(TYPES_SUGGERES).optional().or(z.literal('')).transform((v) => (v ? v : undefined)),
|
||||
// Honeypot anti-spam : champ caché, doit rester vide pour un humain.
|
||||
site_web: z.string().optional().or(z.literal('')),
|
||||
})
|
||||
.refine((data) => data.liens.length > 0 || data.texte.trim().length > 0, {
|
||||
message: 'Ajoute au moins un lien ou quelques mots.',
|
||||
path: ['texte'],
|
||||
})
|
||||
.refine((data) => !data.type_secondaire || !!data.type_suggere, {
|
||||
message: 'Choisis d\'abord un type principal.',
|
||||
path: ['type_secondaire'],
|
||||
})
|
||||
.refine((data) => !data.type_secondaire || data.type_secondaire !== data.type_suggere, {
|
||||
message: 'Le second type doit être différent du premier.',
|
||||
path: ['type_secondaire'],
|
||||
})
|
||||
|
||||
export type LibreSubmitInput = z.infer<typeof LibreSubmitSchema>
|
||||
|
||||
/** Chip 'jobs'/'outils' → valeur `submission_type` en base (table orgas). */
|
||||
const TYPE_TO_SUBMISSION_TYPE: Record<Exclude<TypeSuggere, 'references'>, 'ecosysteme' | 'reseau' | 'job' | 'outil'> = {
|
||||
ecosysteme: 'ecosysteme',
|
||||
reseau: 'reseau',
|
||||
jobs: 'job',
|
||||
outils: 'outil',
|
||||
}
|
||||
|
||||
function hostnameOf(url: string): string | null {
|
||||
try {
|
||||
return new URL(url).hostname.replace(/^www\./, '')
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function nomSuggere(data: LibreSubmitInput): string {
|
||||
const premierLien = data.liens[0]
|
||||
const base = premierLien ? hostnameOf(premierLien) : null
|
||||
const fallback = data.texte.trim().slice(0, 60)
|
||||
return `[à qualifier] ${base || fallback || 'sans titre'}`
|
||||
}
|
||||
|
||||
/**
|
||||
* `prefixLine` est soit le marqueur worker « Type : non précisé » (aucun type choisi),
|
||||
* soit la ligne « Type suggéré : X ou Y » (double type) — jamais les deux à la fois,
|
||||
* et toujours en toute première position quand présent (contrat worker/enrich.js:562).
|
||||
*/
|
||||
function descriptionAvecLiens(texte: string, liens: string[], prefixLine?: string): string {
|
||||
const parts: string[] = []
|
||||
if (prefixLine) parts.push(prefixLine)
|
||||
if (texte.trim()) parts.push(texte.trim())
|
||||
if (liens.length) parts.push(`Liens :\n${liens.join('\n')}`)
|
||||
return parts.join('\n\n')
|
||||
}
|
||||
|
||||
function ligneTypeSuggere(data: LibreSubmitInput): string | undefined {
|
||||
if (!data.type_secondaire) return undefined
|
||||
// Le schéma garantit type_suggere défini dès que type_secondaire l'est.
|
||||
return `Type suggéré : ${TYPES_SUGGERES_LABELS[data.type_suggere!]} ou ${TYPES_SUGGERES_LABELS[data.type_secondaire]}`
|
||||
}
|
||||
|
||||
/** Mapping vers la table orgas existante (submission_type ecosysteme/reseau/job/outil). */
|
||||
export function toOrgaPayload(data: LibreSubmitInput) {
|
||||
const hasSecondaire = !!data.type_secondaire
|
||||
// Marqueur worker posé uniquement quand aucun type exploitable n'a été choisi du tout —
|
||||
// un double type ne le pose jamais (contrat worker/enrich.js:562).
|
||||
const typeNonPrecise = !hasSecondaire && (!data.type_suggere || data.type_suggere === 'references')
|
||||
const submissionType = data.type_suggere && data.type_suggere !== 'references'
|
||||
? TYPE_TO_SUBMISSION_TYPE[data.type_suggere]
|
||||
: 'ecosysteme'
|
||||
const prefixLine = typeNonPrecise ? 'Type : non précisé' : ligneTypeSuggere(data)
|
||||
|
||||
return {
|
||||
submission_type: submissionType,
|
||||
nom: nomSuggere(data),
|
||||
url: data.liens[0] || null,
|
||||
description_user: descriptionAvecLiens(data.texte, data.liens, prefixLine),
|
||||
submitted_by_email: data.submitted_by_email || null,
|
||||
moderation_status: 'pending' as const,
|
||||
ai_processed: false,
|
||||
submitted_at: new Date().toISOString(),
|
||||
scrape_status: data.liens[0] ? 'pending' : 'no_link',
|
||||
}
|
||||
}
|
||||
|
||||
/** Mapping vers la table ressources_references (chip « Références »). */
|
||||
export function toReferencePayload(data: LibreSubmitInput) {
|
||||
return {
|
||||
titre: nomSuggere(data),
|
||||
auteur: 'non précisé',
|
||||
url: data.liens[0] || null,
|
||||
description: descriptionAvecLiens(data.texte, data.liens, ligneTypeSuggere(data)),
|
||||
pertinence_rag: null,
|
||||
hashtags: null,
|
||||
submitted_by_email: data.submitted_by_email || null,
|
||||
moderation_status: 'pending' as const,
|
||||
submitted_at: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
@@ -1,234 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
// worker/daily-digest.js — Email récap quotidien nouvelles fiches AEP
|
||||
// Cron : 0 8 * * * (tous les jours à 8h)
|
||||
// Usage : node worker/daily-digest.js
|
||||
|
||||
import 'dotenv/config'
|
||||
|
||||
// ─── CONFIG DEPUIS .env ───────────────────────────────────────────────────────
|
||||
const NOCODB_URL = process.env.NOCODB_URL || 'http://localhost:8070';
|
||||
const NOCODB_TOKEN = process.env.NOCODB_TOKEN;
|
||||
const NOCODB_BASE = process.env.NOCODB_BASE;
|
||||
const NOCODB_TABLE_ORGAS = process.env.NOCODB_TABLE_ORGAS;
|
||||
const RESEND_API_KEY = process.env.RESEND_API_KEY;
|
||||
const RESEND_FROM = process.env.RESEND_FROM || 'AEP Digest <noreply@trans-former.fr>';
|
||||
const EMAIL_DEST = process.env.EMAIL_JULES || 'transformationsresilientes@gmail.com';
|
||||
const NOCODB_ADMIN_URL = process.env.NOCODB_ADMIN_URL || 'http://localhost:8070';
|
||||
|
||||
// ─── UTILITAIRES LOG ─────────────────────────────────────────────────────────
|
||||
function log(...args) {
|
||||
const ts = new Date().toISOString();
|
||||
console.log(`[${ts}]`, ...args);
|
||||
}
|
||||
|
||||
// ─── NOCODB — FETCH FICHES DERNIÈRES 24H ─────────────────────────────────────
|
||||
async function fetchNewFiches() {
|
||||
const since = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
|
||||
|
||||
// NocoDB ne supporte pas bien le filtre datetime sur CreatedAt — on récupère
|
||||
// les 200 dernières fiches triées par date décroissante et on filtre en JS
|
||||
// (même pattern que getBudgetMoisCourant dans enrich.js)
|
||||
const url = `${NOCODB_URL}/api/v1/db/data/noco/${NOCODB_BASE}/${NOCODB_TABLE_ORGAS}?limit=200&sort=-CreatedAt`;
|
||||
|
||||
const res = await fetch(url, {
|
||||
headers: { 'xc-token': NOCODB_TOKEN }
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`NocoDB GET fiches → ${res.status}: ${await res.text()}`);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
const rows = data.list || [];
|
||||
|
||||
// Filtre JS sur les 24 dernières heures
|
||||
const recent = rows.filter(row => {
|
||||
const created = new Date(row.CreatedAt || row.created_at || 0);
|
||||
return created >= new Date(since);
|
||||
});
|
||||
|
||||
return recent;
|
||||
}
|
||||
|
||||
// ─── CONSTRUCTION EMAIL HTML ─────────────────────────────────────────────────
|
||||
function buildEmailHtml(fiches) {
|
||||
const count = fiches.length;
|
||||
const date = new Date().toLocaleDateString('fr-FR', {
|
||||
weekday: 'long', day: 'numeric', month: 'long', year: 'numeric'
|
||||
});
|
||||
|
||||
const lignes = fiches.map(f => {
|
||||
const nom = f.nom || '(sans nom)';
|
||||
const url = f.url || null;
|
||||
const echelle = f.echelle || '—';
|
||||
const statut = f.moderation_status || 'pending';
|
||||
const ficheUrl = `https://aep.trans-former.fr/fiche/${f.Id}`;
|
||||
|
||||
const nomHtml = url
|
||||
? `<a href="${url}" style="color:#1a56db;text-decoration:none;">${escHtml(nom)}</a>`
|
||||
: escHtml(nom);
|
||||
|
||||
const statutColor = statut === 'approved' ? '#057a55'
|
||||
: statut === 'rejected' ? '#c81e1e'
|
||||
: statut === 'ai_processed' ? '#1a56db'
|
||||
: '#9ca3af'; // pending / autre
|
||||
|
||||
return `
|
||||
<tr style="border-bottom:1px solid #f3f4f6;">
|
||||
<td style="padding:10px 12px;font-size:14px;">${nomHtml}</td>
|
||||
<td style="padding:10px 12px;font-size:13px;color:#6b7280;">${escHtml(echelle)}</td>
|
||||
<td style="padding:10px 12px;">
|
||||
<span style="display:inline-block;padding:2px 8px;border-radius:4px;font-size:12px;
|
||||
font-weight:600;background:#f3f4f6;color:${statutColor};">
|
||||
${escHtml(statut)}
|
||||
</span>
|
||||
</td>
|
||||
<td style="padding:10px 12px;font-size:12px;">
|
||||
<a href="${ficheUrl}" style="color:#6b7280;">fiche #${f.Id}</a>
|
||||
</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head>
|
||||
<body style="margin:0;padding:0;background:#f9fafb;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" style="background:#f9fafb;padding:32px 0;">
|
||||
<tr><td align="center">
|
||||
<table width="600" cellpadding="0" cellspacing="0" style="background:#ffffff;border-radius:8px;overflow:hidden;box-shadow:0 1px 3px rgba(0,0,0,0.1);">
|
||||
|
||||
<!-- Header -->
|
||||
<tr>
|
||||
<td style="background:#1a56db;padding:24px 32px;">
|
||||
<p style="margin:0;font-size:12px;color:#bfdbfe;text-transform:uppercase;letter-spacing:1px;">AEP — Agences En Pratique</p>
|
||||
<h1 style="margin:4px 0 0;font-size:20px;color:#ffffff;font-weight:700;">
|
||||
${count} nouvelle${count > 1 ? 's' : ''} fiche${count > 1 ? 's' : ''} soumise${count > 1 ? 's' : ''}
|
||||
</h1>
|
||||
<p style="margin:4px 0 0;font-size:13px;color:#bfdbfe;">${date}</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Corps -->
|
||||
<tr>
|
||||
<td style="padding:24px 32px;">
|
||||
<p style="margin:0 0 16px;font-size:14px;color:#374151;">
|
||||
${count} fiche${count > 1 ? 's ont été soumises' : ' a été soumise'} dans les dernières 24 heures.
|
||||
</p>
|
||||
|
||||
<!-- Tableau fiches -->
|
||||
<table width="100%" cellpadding="0" cellspacing="0" style="border-collapse:collapse;border:1px solid #e5e7eb;border-radius:6px;overflow:hidden;">
|
||||
<thead>
|
||||
<tr style="background:#f9fafb;">
|
||||
<th style="padding:10px 12px;text-align:left;font-size:12px;font-weight:600;color:#6b7280;text-transform:uppercase;letter-spacing:0.5px;">Organisation</th>
|
||||
<th style="padding:10px 12px;text-align:left;font-size:12px;font-weight:600;color:#6b7280;text-transform:uppercase;letter-spacing:0.5px;">Échelle</th>
|
||||
<th style="padding:10px 12px;text-align:left;font-size:12px;font-weight:600;color:#6b7280;text-transform:uppercase;letter-spacing:0.5px;">Statut</th>
|
||||
<th style="padding:10px 12px;text-align:left;font-size:12px;font-weight:600;color:#6b7280;text-transform:uppercase;letter-spacing:0.5px;">Lien</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${lignes}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- CTA modération -->
|
||||
<div style="margin-top:24px;text-align:center;">
|
||||
<a href="${NOCODB_ADMIN_URL}"
|
||||
style="display:inline-block;padding:10px 20px;background:#1a56db;color:#ffffff;
|
||||
border-radius:6px;font-size:14px;font-weight:600;text-decoration:none;">
|
||||
Ouvrir NocoDB pour modérer
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Footer -->
|
||||
<tr>
|
||||
<td style="padding:16px 32px;border-top:1px solid #f3f4f6;">
|
||||
<p style="margin:0;font-size:11px;color:#9ca3af;text-align:center;">
|
||||
Digest automatique AEP — envoyé chaque matin à 8h
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
</td></tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
// Échappement HTML minimal
|
||||
function escHtml(str) {
|
||||
return String(str ?? '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
// ─── ENVOI VIA RESEND ─────────────────────────────────────────────────────────
|
||||
async function sendDigest(fiches) {
|
||||
const count = fiches.length;
|
||||
const subject = `AEP — ${count} nouvelle${count > 1 ? 's' : ''} fiche${count > 1 ? 's' : ''} soumise${count > 1 ? 's' : ''}`;
|
||||
const html = buildEmailHtml(fiches);
|
||||
|
||||
const res = await fetch('https://api.resend.com/emails', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${RESEND_API_KEY}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
from: RESEND_FROM,
|
||||
to: [EMAIL_DEST],
|
||||
subject,
|
||||
html
|
||||
})
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.text();
|
||||
throw new Error(`Resend API ${res.status}: ${err}`);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
log(`Email digest envoyé → ${EMAIL_DEST} | id: ${data.id} | sujet: "${subject}"`);
|
||||
}
|
||||
|
||||
// ─── MAIN ─────────────────────────────────────────────────────────────────────
|
||||
async function run() {
|
||||
log('=== daily-digest démarré ===');
|
||||
|
||||
// Vérification variables obligatoires
|
||||
if (!NOCODB_TOKEN || !NOCODB_BASE || !NOCODB_TABLE_ORGAS) {
|
||||
log('ERREUR : Variables NocoDB manquantes (NOCODB_TOKEN, NOCODB_BASE, NOCODB_TABLE_ORGAS)');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!RESEND_API_KEY) {
|
||||
log('ERREUR : RESEND_API_KEY manquante');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
// Fetch fiches des 24 dernières heures
|
||||
const fiches = await fetchNewFiches();
|
||||
log(`${fiches.length} fiche(s) trouvée(s) dans les dernières 24h`);
|
||||
|
||||
if (fiches.length === 0) {
|
||||
log('Aucune nouvelle fiche — digest non envoyé.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Envoi email
|
||||
await sendDigest(fiches);
|
||||
log('=== daily-digest terminé avec succès ===');
|
||||
|
||||
} catch (e) {
|
||||
log('ERREUR daily-digest:', e.message);
|
||||
console.error(e.stack);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
run();
|
||||
+286
-184
@@ -1,14 +1,28 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* NAV V2 — Worker enrichissement IA
|
||||
* Lancé via systemd timer toutes les 5 minutes
|
||||
* Pipeline : fetch pending → scrape crawl4ai → Mistral Nemo → update NocoDB → log stats
|
||||
* AEP — Worker enrichissement IA (B5-M2)
|
||||
* Lancé via systemd timer toutes les 15 minutes (deploy/aep-worker/aep-worker.timer)
|
||||
* Pipeline : fetch pending → scrape léger (fetch natif) → Bifrost (LLM) → update NocoDB → ntfy
|
||||
*
|
||||
* Écarts à la version NAV V2 d'origine (PIPE-IA-DOC.md, fusionné pas réécrit) :
|
||||
* - LLM : Bifrost (passerelle OpenAI-compatible en prod) au lieu de Mistral direct.
|
||||
* - Scrape : fetch natif Node 22 (timeout 8s, 500 Ko max, texte tronqué 4000 car.)
|
||||
* au lieu de crawl4ai/Python (disque VPS tendu, décision MOE 27/09).
|
||||
* - Notification : ntfy (https://ntfy.sh/$NTFY_TOPIC) au lieu de Resend (abandonné 15/07).
|
||||
* Le message ne contient JAMAIS l'email ni le texte libre du contributeur.
|
||||
* - Le formulaire assoupli (B5-M1) écrit un `nom` placeholder ("[à qualifier] host") et
|
||||
* éventuellement "Type : non précisé" en tête de description_user. Le worker propose un
|
||||
* vrai nom et un type — voir §Sortie JSON ci-dessous et PIPE-IA-DOC.md.
|
||||
* - Seuil « email à 5 fiches pending » PAS réactivé (décision MOE : volume faible, une
|
||||
* notif par fiche traitée suffit — cf. B5-proposer-pipe.md §Points d'attention).
|
||||
*/
|
||||
|
||||
import { spawnSync, execSync } from 'child_process';
|
||||
import { existsSync, writeFileSync, unlinkSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
import { existsSync, writeFileSync, unlinkSync, readFileSync, mkdirSync } from 'fs';
|
||||
import { dirname, join } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
// ─── CONFIG DEPUIS .env ───────────────────────────────────────────────────────
|
||||
const NOCODB_URL = process.env.NOCODB_URL || 'http://localhost:8070';
|
||||
@@ -16,25 +30,44 @@ const NOCODB_TOKEN = process.env.NOCODB_TOKEN;
|
||||
const NOCODB_BASE = process.env.NOCODB_BASE;
|
||||
const NOCODB_TABLE_ORGAS = process.env.NOCODB_TABLE_ORGAS;
|
||||
const NOCODB_TABLE_STATS = process.env.NOCODB_TABLE_STATS;
|
||||
const MISTRAL_API_KEY = process.env.MISTRAL_API_KEY;
|
||||
const RESEND_API_KEY = process.env.RESEND_API_KEY;
|
||||
const RESEND_FROM = process.env.RESEND_FROM || 'contact@trans-former.fr';
|
||||
const EMAIL_JULES = process.env.EMAIL_JULES || 'jules@trans-former.fr';
|
||||
|
||||
const BIFROST_URL = process.env.BIFROST_URL || 'http://127.0.0.1:8080';
|
||||
const BIFROST_VK = process.env.BIFROST_VK;
|
||||
const WORKER_MODEL = process.env.WORKER_MODEL || 'groq/openai/gpt-oss-20b'; // llama-3.1-8b-instant retiré par Groq (28/09)
|
||||
|
||||
const NTFY_TOPIC = process.env.NTFY_TOPIC;
|
||||
|
||||
const BUDGET_MAX_EUR = parseFloat(process.env.BUDGET_MAX_EUR || '20');
|
||||
const WORKER_LIMIT = parseInt(process.env.WORKER_LIMIT || '5');
|
||||
const LOCK_FILE = '/tmp/nav-worker.lock';
|
||||
const LOCK_FILE = process.env.WORKER_LOCK_FILE || '/tmp/aep-worker.lock';
|
||||
|
||||
// ─── PRIX MISTRAL NEMO (USD → EUR) ───────────────────────────────────────────
|
||||
const NEMO_PRICE_IN = 0.02 / 1_000_000; // $0.02 / 1M tokens input
|
||||
const NEMO_PRICE_OUT = 0.04 / 1_000_000; // $0.04 / 1M tokens output
|
||||
// Prix par 1M tokens (USD) — par défaut 0 (Groq gratuit dans le tier RAPIDE Bifrost).
|
||||
// Réglable si le worker bascule un jour sur un modèle payant.
|
||||
const WORKER_PRICE_IN = parseFloat(process.env.WORKER_PRICE_IN_USD_PER_M || '0') / 1_000_000;
|
||||
const WORKER_PRICE_OUT = parseFloat(process.env.WORKER_PRICE_OUT_USD_PER_M || '0') / 1_000_000;
|
||||
const USD_TO_EUR = 0.93;
|
||||
|
||||
// Scrape léger
|
||||
const SCRAPE_TIMEOUT_MS = 8_000;
|
||||
const SCRAPE_MAX_BYTES = 500_000;
|
||||
const SCRAPE_TEXT_MAX_CHARS = 4_000;
|
||||
const SCRAPE_USER_AGENT = 'AEP/2.0 contact@trans-former.fr';
|
||||
|
||||
// ─── CLI ──────────────────────────────────────────────────────────────────────
|
||||
const DRY_RUN = process.argv.includes('--dry-run');
|
||||
// DRY_RUN_LIVE=1 : en dry-run, tente quand même de vrais appels réseau (scrape + Bifrost)
|
||||
// pour un test manuel avec accès réseau — ne touche JAMAIS NocoDB en dry-run, dans tous les cas.
|
||||
const DRY_RUN_LIVE = process.env.DRY_RUN_LIVE === '1';
|
||||
const DRY_RUN_FIXTURE = process.env.DRY_RUN_FIXTURE || join(__dirname, 'fixtures', 'dry-run-rows.json');
|
||||
|
||||
// ─── TAXONOMIE VALIDE (apostrophe typographique U+2019 comme NocoDB) ─────────
|
||||
const VALID_FONCTIONS = [
|
||||
'Juridique', 'Technique', 'Économique', 'Administratif', 'Chantier',
|
||||
'Comptabilité', 'Développement', 'Formation', 'Gestion d\u2019agence', 'Santé mentale'
|
||||
'Comptabilité', 'Développement', 'Formation', 'Gestion d’agence', 'Santé mentale'
|
||||
];
|
||||
|
||||
const VALID_SUBMISSION_TYPES = ['ecosysteme', 'reseau', 'job', 'outil'];
|
||||
|
||||
// ─── MAPPING NORMALISATION TAGS ───────────────────────────────────────────────
|
||||
const TAG_MAP = [
|
||||
[['juridique', 'droit', 'litige', 'contrat', 'déontologie', 'décennale', 'médiation', 'pi ', 'propriété intellectuelle', 'ccag', 'marchés publics droit'], 'Juridique'],
|
||||
@@ -45,16 +78,14 @@ const TAG_MAP = [
|
||||
[['comptabilité', 'fiscal', 'tva', 'bnc', 'bic', 'expert-comptable', 'bilan', 'trésorerie', 'transmission agence', 'création agence', 'micro'], 'Comptabilité'],
|
||||
[['développement', 'prospection', 'commercial', 'client', 'réseau', 'candidature', 'consultation', 'acquisition', 'marketing', 'notoriété', 'ao '], 'Développement'],
|
||||
[['formation', 'école', 'mooc', 'organisme', 'formation continue', 'cpf', 'dpc', 'cfaa'], 'Formation'],
|
||||
[['gestion d\u2019agence', 'gestion d\'agence', 'rh', 'recrutement', 'emploi', 'salaire', 'ccn', 'convention collective', 'idcc', 'temps de travail', 'management'], 'Gestion d\u2019agence'],
|
||||
[['gestion d’agence', 'gestion d\'agence', 'rh', 'recrutement', 'emploi', 'salaire', 'ccn', 'convention collective', 'idcc', 'temps de travail', 'management'], 'Gestion d’agence'],
|
||||
[['santé mentale', 'burn-out', 'épuisement', 'souffrance', 'bien-être', 'harcèlement', 'stress', 'psychologique', 'équilibre'], 'Santé mentale'],
|
||||
];
|
||||
|
||||
function normalizeTag(raw) {
|
||||
const t = raw.toLowerCase().trim();
|
||||
// D'abord chercher correspondance exacte dans les valeurs valides
|
||||
const t = String(raw).toLowerCase().trim();
|
||||
const exact = VALID_FONCTIONS.find(v => v.toLowerCase() === t);
|
||||
if (exact) return exact;
|
||||
// Sinon chercher par mots-clés
|
||||
for (const [patterns, normalized] of TAG_MAP) {
|
||||
if (patterns.some(p => t.includes(p))) return normalized;
|
||||
}
|
||||
@@ -117,8 +148,18 @@ async function nocodbPost(tableId, data) {
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/** Écrit une mise à jour de fiche — no-op loggé en dry-run (jamais de PATCH réel). */
|
||||
async function patchRow(rowId, data) {
|
||||
if (DRY_RUN) {
|
||||
log(`[dry-run] PATCH fiche ${rowId} :`, JSON.stringify(data));
|
||||
return;
|
||||
}
|
||||
return nocodbPatch(NOCODB_TABLE_ORGAS, rowId, data);
|
||||
}
|
||||
|
||||
// ─── BUDGET CIRCUIT BREAKER ──────────────────────────────────────────────────
|
||||
async function getBudgetMoisCourant() {
|
||||
if (DRY_RUN) return 0;
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = now.getMonth(); // 0-indexed
|
||||
@@ -142,7 +183,12 @@ async function getBudgetMoisCourant() {
|
||||
async function logUsage(usage, model, endpoint, orgaId) {
|
||||
const tokensIn = usage?.prompt_tokens || 0;
|
||||
const tokensOut = usage?.completion_tokens || 0;
|
||||
const coutEur = ((tokensIn * NEMO_PRICE_IN) + (tokensOut * NEMO_PRICE_OUT)) * USD_TO_EUR;
|
||||
const coutEur = ((tokensIn * WORKER_PRICE_IN) + (tokensOut * WORKER_PRICE_OUT)) * USD_TO_EUR;
|
||||
|
||||
if (DRY_RUN) {
|
||||
log(`[dry-run] Usage (non loggé) : ${tokensIn}in + ${tokensOut}out = €${coutEur.toFixed(6)} (${model})`);
|
||||
return coutEur;
|
||||
}
|
||||
|
||||
await nocodbPost(NOCODB_TABLE_STATS, {
|
||||
model,
|
||||
@@ -166,111 +212,168 @@ async function fetchPendingRows() {
|
||||
return data.list || [];
|
||||
}
|
||||
|
||||
// ─── SCRAPING CRAWL4AI (mode HTTP statique, sans Playwright) ─────────────────
|
||||
async function scrapeWithCrawl4ai(url) {
|
||||
log(`Scraping: ${url}`);
|
||||
function loadFixtureRows() {
|
||||
if (!existsSync(DRY_RUN_FIXTURE)) {
|
||||
throw new Error(`Fixture dry-run introuvable : ${DRY_RUN_FIXTURE}`);
|
||||
}
|
||||
const raw = JSON.parse(readFileSync(DRY_RUN_FIXTURE, 'utf-8'));
|
||||
return Array.isArray(raw) ? raw : [raw];
|
||||
}
|
||||
|
||||
// Script Python temporaire pour crawl4ai
|
||||
const urlSafe = url.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
|
||||
const script = `
|
||||
import asyncio, sys
|
||||
from crawl4ai import AsyncWebCrawler, CrawlerRunConfig
|
||||
from crawl4ai.async_crawler_strategy import AsyncHTTPCrawlerStrategy
|
||||
|
||||
async def scrape():
|
||||
strategy = AsyncHTTPCrawlerStrategy()
|
||||
run_cfg = CrawlerRunConfig(
|
||||
word_count_threshold=20,
|
||||
excluded_tags=['nav', 'footer', 'script', 'style', 'head'],
|
||||
remove_overlay_elements=True
|
||||
)
|
||||
async with AsyncWebCrawler(crawler_strategy=strategy, verbose=False) as crawler:
|
||||
result = await crawler.arun(url='${urlSafe}', config=run_cfg)
|
||||
if result.success and result.markdown:
|
||||
content = result.markdown[:16000]
|
||||
sys.stdout.buffer.write(content.encode('utf-8'))
|
||||
else:
|
||||
sys.stderr.write(f"Scrape failed: success={result.success}\\n")
|
||||
sys.exit(1)
|
||||
|
||||
asyncio.run(scrape())
|
||||
`;
|
||||
|
||||
const scriptPath = join(tmpdir(), `nav-scrape-${Date.now()}.py`);
|
||||
writeFileSync(scriptPath, script, 'utf-8');
|
||||
// ─── LIENS — extraits de url + description_user (format B5-M1 "Liens :\n...") ─
|
||||
function extractLinks(row) {
|
||||
const text = `${row.url || ''}\n${row.description_user || row.description || ''}`;
|
||||
const matches = text.match(/https?:\/\/[^\s)"'<>]+/g) || [];
|
||||
const cleaned = matches.map(u => u.replace(/[.,;:!?]+$/, ''));
|
||||
return [...new Set(cleaned)];
|
||||
}
|
||||
|
||||
// ─── SCRAPE LÉGER (fetch natif, pas de crawl4ai) ─────────────────────────────
|
||||
async function fetchCapped(url) {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), SCRAPE_TIMEOUT_MS);
|
||||
try {
|
||||
const result = spawnSync('python3', [scriptPath], {
|
||||
timeout: 180_000,
|
||||
maxBuffer: 20 * 1024 * 1024,
|
||||
env: { ...process.env, PYTHONDONTWRITEBYTECODE: '1', PYTHONIOENCODING: 'utf-8' }
|
||||
const res = await fetch(url, {
|
||||
signal: controller.signal,
|
||||
redirect: 'follow',
|
||||
headers: { 'User-Agent': SCRAPE_USER_AGENT },
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
|
||||
if (result.error) throw result.error;
|
||||
const reader = res.body?.getReader();
|
||||
if (!reader) return await res.text();
|
||||
|
||||
const stdout = result.stdout?.toString('utf-8') || '';
|
||||
const stderr = result.stderr?.toString('utf-8') || '';
|
||||
|
||||
if (result.status === 0 && stdout.length > 50) {
|
||||
log(`Scrape OK: ${stdout.length} chars`);
|
||||
return stdout.trim();
|
||||
} else {
|
||||
throw new Error(`Scrape failed (code ${result.status}): ${stderr.slice(0, 300)}`);
|
||||
const chunks = [];
|
||||
let total = 0;
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
chunks.push(value);
|
||||
total += value.length;
|
||||
if (total >= SCRAPE_MAX_BYTES) {
|
||||
await reader.cancel().catch(() => {});
|
||||
break;
|
||||
}
|
||||
}
|
||||
const buf = Buffer.concat(chunks.map(c => Buffer.from(c)));
|
||||
return buf.subarray(0, SCRAPE_MAX_BYTES).toString('utf-8');
|
||||
} finally {
|
||||
try { unlinkSync(scriptPath); } catch {}
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── APPEL MISTRAL NEMO ───────────────────────────────────────────────────────
|
||||
const SYSTEM_PROMPT = `Tu es un assistant spécialisé dans l'écosystème professionnel de l'architecture en France. Tu reçois des informations sur une organisation ou ressource liée au secteur de l'architecture, et tu dois les enrichir pour alimenter une cartographie collaborative.
|
||||
function extractMeta(html, key) {
|
||||
const re1 = new RegExp(`<meta[^>]*(?:name|property)=["']${key}["'][^>]*content=["']([^"']*)["']`, 'i');
|
||||
const re2 = new RegExp(`<meta[^>]*content=["']([^"']*)["'][^>]*(?:name|property)=["']${key}["']`, 'i');
|
||||
return (html.match(re1) || html.match(re2))?.[1]?.trim() || null;
|
||||
}
|
||||
|
||||
function extractOgTags(html) {
|
||||
const og = {};
|
||||
const re1 = /<meta[^>]*property=["']og:([a-zA-Z:_-]+)["'][^>]*content=["']([^"']*)["']/gi;
|
||||
const re2 = /<meta[^>]*content=["']([^"']*)["'][^>]*property=["']og:([a-zA-Z:_-]+)["']/gi;
|
||||
let m;
|
||||
while ((m = re1.exec(html))) og[m[1]] = m[2];
|
||||
while ((m = re2.exec(html))) if (!(m[2] in og)) og[m[2]] = m[1];
|
||||
return og;
|
||||
}
|
||||
|
||||
function decodeEntities(s) {
|
||||
return s
|
||||
.replace(/ /g, ' ')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/�?39;/g, "'");
|
||||
}
|
||||
|
||||
function extractVisibleText(html) {
|
||||
let s = html
|
||||
.replace(/<!--[\s\S]*?-->/g, ' ')
|
||||
.replace(/<script[\s\S]*?<\/script>/gi, ' ')
|
||||
.replace(/<style[\s\S]*?<\/style>/gi, ' ')
|
||||
.replace(/<nav[\s\S]*?<\/nav>/gi, ' ')
|
||||
.replace(/<footer[\s\S]*?<\/footer>/gi, ' ')
|
||||
.replace(/<head[\s\S]*?<\/head>/gi, ' ');
|
||||
s = s.replace(/<[^>]+>/g, ' ');
|
||||
s = decodeEntities(s);
|
||||
s = s.replace(/\s+/g, ' ').trim();
|
||||
return s.slice(0, SCRAPE_TEXT_MAX_CHARS);
|
||||
}
|
||||
|
||||
function extractTitle(html) {
|
||||
return html.match(/<title[^>]*>([^<]*)<\/title>/i)?.[1]?.trim() || null;
|
||||
}
|
||||
|
||||
async function scrapeLight(url) {
|
||||
log(`Scraping léger: ${url}`);
|
||||
const html = await fetchCapped(url);
|
||||
return {
|
||||
title: extractTitle(html),
|
||||
metaDescription: extractMeta(html, 'description'),
|
||||
og: extractOgTags(html),
|
||||
text: extractVisibleText(html),
|
||||
};
|
||||
}
|
||||
|
||||
const MOCK_SCRAPE_RESULT = {
|
||||
title: '[mock dry-run] Titre de la page',
|
||||
metaDescription: '[mock dry-run] Meta description factice, aucun réseau contacté.',
|
||||
og: { site_name: '[mock dry-run]' },
|
||||
text: '[mock dry-run] Extrait de texte factice utilisé quand DRY_RUN_LIVE n’est pas activé.',
|
||||
};
|
||||
|
||||
// ─── APPEL LLM VIA BIFROST ────────────────────────────────────────────────────
|
||||
const SYSTEM_PROMPT = `Tu es un assistant qui aide à qualifier des ressources soumises pour une cartographie collaborative de l'écosystème professionnel de l'architecture en France (AEP).
|
||||
|
||||
RÈGLES ABSOLUES :
|
||||
1. Tu ne dois JAMAIS inventer d'informations non présentes dans les sources fournies.
|
||||
2. Si une information est absente ou incertaine, retourne \`null\` pour ce champ.
|
||||
3. Tu dois retourner UNIQUEMENT un objet JSON valide, sans texte avant ou après.
|
||||
4. La description_enrichie doit être neutre, factuelle, en français, sans jugement de valeur.
|
||||
5. Les points_cles sont des phrases courtes (max 12 mots chacune), actionnables pour un architecte.
|
||||
6. Pour les tags_fonction, ne propose que des valeurs parmi la liste autorisée.
|
||||
4. "description" : neutre, factuelle, en français, max 300 caractères, sans jugement de valeur.
|
||||
5. "nom" : un nom de fiche court et identifiable, sans le préfixe "[à qualifier]".
|
||||
6. "tags" : 1 à 5 valeurs, uniquement parmi la liste autorisée.
|
||||
|
||||
TAXONOMIE AUTORISÉE :
|
||||
- Échelle (une seule valeur) : "National" | "Régional" | "Départemental" | "Local"
|
||||
- Territoire (une seule valeur) : "Métropole" | "Guadeloupe" | "Martinique" | "Guyane" | "Réunion" | "Mayotte" | null
|
||||
- Tags fonction (1 à 5 valeurs) : "Juridique" | "Technique" | "Économique" | "Administratif" | "Chantier" | "Comptabilité" | "Développement" | "Formation" | "Gestion d\u2019agence" | "Santé mentale"
|
||||
TAGS AUTORISÉS : "Juridique" | "Technique" | "Économique" | "Administratif" | "Chantier" | "Comptabilité" | "Développement" | "Formation" | "Gestion d’agence" | "Santé mentale"
|
||||
TYPE_SUGGERE AUTORISÉ (une seule valeur ou null) : "ecosysteme" | "reseau" | "job" | "outil"
|
||||
|
||||
FORMAT DE SORTIE JSON :
|
||||
{
|
||||
"description_enrichie": "string (max 300 chars, français, neutre, factuel)",
|
||||
"points_cles": ["string", "string", "string"],
|
||||
"tags_fonction": ["Valeur1", "Valeur2"],
|
||||
"echelle": "National" | "Régional" | "Départemental" | "Local" | null,
|
||||
"territoire": "Métropole" | ... | null,
|
||||
"localisation_ville": "string" | null,
|
||||
"nom": "string | null",
|
||||
"description": "string (max 300 chars, français, neutre, factuel) | null",
|
||||
"type_suggere": "ecosysteme" | "reseau" | "job" | "outil" | null,
|
||||
"ville": "string | null",
|
||||
"tags": ["string", "..."],
|
||||
"confiance": "haute" | "moyenne" | "faible"
|
||||
}
|
||||
|
||||
Le champ "confiance" reflète ta certitude globale sur l'enrichissement :
|
||||
- "haute" : URL scrapée avec contenu riche, informations claires
|
||||
- "moyenne" : URL scrapée mais contenu partiel, ou description_user seule suffisante
|
||||
- "faible" : URL non disponible et description_user vague, inférences importantes`;
|
||||
Le champ "confiance" reflète ta certitude globale :
|
||||
- "haute" : contenu scrapé riche, informations claires.
|
||||
- "moyenne" : contenu partiel, ou texte du contributeur seul mais clair.
|
||||
- "faible" : aucun contenu scrapé et texte du contributeur vague, inférences importantes.`;
|
||||
|
||||
function buildUserPrompt(row, scrapeContent) {
|
||||
return `ORGANISATION À ENRICHIR :
|
||||
function buildUserPrompt(row, scrapeData, autresLiens) {
|
||||
return `RESSOURCE À QUALIFIER :
|
||||
|
||||
Nom : ${row.nom}
|
||||
URL : ${row.url || 'non fournie'}
|
||||
Description soumise par l'utilisateur : ${row.description_user || row.description || 'non fournie'}
|
||||
Nom actuel (placeholder à remplacer) : ${row.nom}
|
||||
Lien principal : ${row.url || 'non fourni'}
|
||||
${autresLiens.length ? `Autres liens mentionnés par le contributeur : ${autresLiens.join(', ')}` : ''}
|
||||
Texte du contributeur (pourquoi c'est pertinent pour lui) : ${row.description_user || row.description || 'non fourni'}
|
||||
|
||||
CONTENU DU SITE WEB (extrait par scraping) :
|
||||
${scrapeContent || 'Site non accessible ou URL non fournie.'}
|
||||
CONTENU EXTRAIT DU SITE (scraping léger) :
|
||||
Titre : ${scrapeData?.title || 'non disponible'}
|
||||
Meta description : ${scrapeData?.metaDescription || 'non disponible'}
|
||||
Open Graph : ${scrapeData && Object.keys(scrapeData.og || {}).length ? JSON.stringify(scrapeData.og) : 'non disponible'}
|
||||
Texte visible (extrait) : ${scrapeData?.text || 'Site non accessible ou lien non fourni.'}
|
||||
|
||||
---
|
||||
|
||||
Enrichis cette fiche selon les règles du system prompt. Retourne uniquement le JSON.`;
|
||||
Qualifie cette ressource selon les règles du system prompt. Retourne uniquement le JSON.`;
|
||||
}
|
||||
|
||||
async function callMistralWithRetry(row, scrapeContent, maxRetries = 2) {
|
||||
const userPrompt = buildUserPrompt(row, scrapeContent);
|
||||
async function callBifrostWithRetry(row, scrapeData, autresLiens, maxRetries = 2) {
|
||||
const userPrompt = buildUserPrompt(row, scrapeData, autresLiens);
|
||||
|
||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||
if (attempt > 0) {
|
||||
@@ -279,14 +382,14 @@ async function callMistralWithRetry(row, scrapeContent, maxRetries = 2) {
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch('https://api.mistral.ai/v1/chat/completions', {
|
||||
const res = await fetch(`${BIFROST_URL}/v1/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${MISTRAL_API_KEY}`,
|
||||
'x-bf-vk': BIFROST_VK,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: 'open-mistral-nemo',
|
||||
model: WORKER_MODEL,
|
||||
temperature: 0.2,
|
||||
max_tokens: 800,
|
||||
response_format: { type: 'json_object' },
|
||||
@@ -300,101 +403,88 @@ async function callMistralWithRetry(row, scrapeContent, maxRetries = 2) {
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.text();
|
||||
throw new Error(`Mistral API ${res.status}: ${err}`);
|
||||
throw new Error(`Bifrost API ${res.status}: ${err}`);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
const content = data.choices?.[0]?.message?.content;
|
||||
if (!content) throw new Error('Réponse Mistral vide');
|
||||
if (!content) throw new Error('Réponse Bifrost vide');
|
||||
|
||||
const parsed = JSON.parse(content);
|
||||
// Attacher usage pour logging
|
||||
parsed._usage = data.usage;
|
||||
parsed._raw = content;
|
||||
return parsed;
|
||||
|
||||
} catch (e) {
|
||||
log(`Erreur Mistral (tentative ${attempt + 1}): ${e.message}`);
|
||||
log(`Erreur Bifrost (tentative ${attempt + 1}): ${e.message}`);
|
||||
if (attempt === maxRetries) return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ─── EMAIL JULES VIA RESEND ───────────────────────────────────────────────────
|
||||
async function sendEmailJules(subject, body) {
|
||||
if (!RESEND_API_KEY) {
|
||||
log('RESEND_API_KEY absent, email skippé');
|
||||
const MOCK_BIFROST_RESULT = {
|
||||
nom: '[mock dry-run] Nom suggéré',
|
||||
description: '[mock dry-run] Description factice générée sans appel réseau.',
|
||||
type_suggere: 'ecosysteme',
|
||||
ville: null,
|
||||
tags: ['Développement'],
|
||||
confiance: 'faible',
|
||||
_usage: { prompt_tokens: 0, completion_tokens: 0 },
|
||||
_raw: '{"mock":"dry-run"}',
|
||||
};
|
||||
|
||||
// ─── NOTIFICATION NTFY (jamais l'email ni le texte du contributeur) ──────────
|
||||
async function notifyNtfy(title, message) {
|
||||
if (DRY_RUN) {
|
||||
log(`[dry-run] ntfy (non envoyé) — ${title} :`, message.replace(/\n/g, ' | '));
|
||||
return;
|
||||
}
|
||||
if (!NTFY_TOPIC) {
|
||||
log('NTFY_TOPIC absent, notification skippée');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await fetch('https://api.resend.com/emails', {
|
||||
const res = await fetch(`https://ntfy.sh/${NTFY_TOPIC}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${RESEND_API_KEY}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
from: RESEND_FROM,
|
||||
to: [EMAIL_JULES],
|
||||
subject,
|
||||
text: body
|
||||
})
|
||||
headers: { Title: title, Priority: '3', Tags: 'inbox_tray' },
|
||||
body: message,
|
||||
});
|
||||
if (!res.ok) log('Email error:', await res.text());
|
||||
else log('Email envoyé à Jules:', subject);
|
||||
if (!res.ok) log('ntfy erreur:', await res.text());
|
||||
else log('ntfy envoyé:', title);
|
||||
} catch (e) {
|
||||
log('Email exception:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── VÉRIFICATION SEUIL 5 FICHES PENDING MODÉRATION ─────────────────────────
|
||||
async function checkModerationQueue() {
|
||||
const data = await nocodbGet(
|
||||
`${NOCODB_TABLE_ORGAS}?where=(moderation_status,eq,ai_processed)&limit=100`
|
||||
);
|
||||
const count = data.pageInfo?.totalRows || 0;
|
||||
if (count >= 5) {
|
||||
log(`Seuil modération atteint: ${count} fiches en attente`);
|
||||
await sendEmailJules(
|
||||
`NAV — ${count} fiches à modérer`,
|
||||
`Bonjour Jules,\n\n${count} fiches ont été enrichies par l'IA et attendent ta validation dans NocoDB.\n\nLien NocoDB : http://localhost:8070\nFiltre : moderation_status = ai_processed\n\nBonne modération !`
|
||||
);
|
||||
log('ntfy exception:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── MAIN ─────────────────────────────────────────────────────────────────────
|
||||
async function run() {
|
||||
if (!acquireLock()) {
|
||||
if (!DRY_RUN && !acquireLock()) {
|
||||
log('Worker déjà en cours, skip');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
log('=== Worker NAV enrichissement démarré ===');
|
||||
log(`=== Worker AEP enrichissement démarré${DRY_RUN ? ' (--dry-run)' : ''} ===`);
|
||||
|
||||
try {
|
||||
// Vérification clés obligatoires
|
||||
if (!NOCODB_TOKEN || !NOCODB_BASE || !NOCODB_TABLE_ORGAS || !MISTRAL_API_KEY) {
|
||||
throw new Error('Variables .env manquantes (NOCODB_TOKEN, NOCODB_BASE, NOCODB_TABLE_ORGAS, MISTRAL_API_KEY)');
|
||||
if (!DRY_RUN) {
|
||||
if (!NOCODB_TOKEN || !NOCODB_BASE || !NOCODB_TABLE_ORGAS || !BIFROST_VK) {
|
||||
throw new Error('Variables .env manquantes (NOCODB_TOKEN, NOCODB_BASE, NOCODB_TABLE_ORGAS, BIFROST_VK)');
|
||||
}
|
||||
}
|
||||
|
||||
// Check budget global
|
||||
const budgetMois = await getBudgetMoisCourant();
|
||||
log(`Budget mois courant: €${budgetMois.toFixed(4)} / €${BUDGET_MAX_EUR}`);
|
||||
|
||||
if (budgetMois >= BUDGET_MAX_EUR) {
|
||||
log('Budget épuisé pour ce mois. Worker en pause.');
|
||||
await sendEmailJules(
|
||||
'NAV — Budget IA épuisé ce mois',
|
||||
`Le budget IA de ${BUDGET_MAX_EUR}€ a été atteint. Le worker est en pause jusqu'au 1er du mois prochain.\n\nConsommation actuelle : €${budgetMois.toFixed(4)}`
|
||||
);
|
||||
await notifyNtfy('[AEP] Budget IA épuisé', `Le budget de ${BUDGET_MAX_EUR}€ a été atteint. Worker en pause jusqu'au 1er du mois prochain.`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch fiches pending
|
||||
const rows = await fetchPendingRows();
|
||||
log(`${rows.length} fiche(s) à traiter`);
|
||||
const rows = DRY_RUN ? loadFixtureRows() : await fetchPendingRows();
|
||||
log(`${rows.length} fiche(s) à traiter${DRY_RUN ? ' (fixture)' : ''}`);
|
||||
|
||||
if (rows.length === 0) {
|
||||
log('Rien à traiter.');
|
||||
@@ -407,71 +497,86 @@ async function run() {
|
||||
const rowStart = Date.now();
|
||||
log(`--- Traitement fiche ${row.Id}: ${row.nom} ---`);
|
||||
|
||||
// Re-check budget avant chaque fiche
|
||||
const budgetCheck = await getBudgetMoisCourant();
|
||||
if (budgetCheck >= BUDGET_MAX_EUR) {
|
||||
log('Budget atteint mid-pipeline, arrêt.');
|
||||
break;
|
||||
}
|
||||
|
||||
// Scraping
|
||||
let scrapeContent = null;
|
||||
const hasUrl = row.url && row.url.trim().length > 0;
|
||||
const shouldScrape = hasUrl && (row.scrape_status === 'pending' || !row.scrape_status);
|
||||
const liens = extractLinks(row);
|
||||
const primaryUrl = row.url && row.url.trim() ? row.url.trim() : null;
|
||||
const autresLiens = liens.filter(l => l !== primaryUrl);
|
||||
|
||||
// Scraping (uniquement le lien principal — un seul champ scrape_content en base)
|
||||
let scrapeData = null;
|
||||
const shouldScrape = primaryUrl && (row.scrape_status === 'pending' || !row.scrape_status);
|
||||
const useNetwork = !DRY_RUN || DRY_RUN_LIVE;
|
||||
|
||||
if (shouldScrape) {
|
||||
try {
|
||||
scrapeContent = await scrapeWithCrawl4ai(row.url);
|
||||
await nocodbPatch(NOCODB_TABLE_ORGAS, row.Id, {
|
||||
scrapeData = useNetwork ? await scrapeLight(primaryUrl) : MOCK_SCRAPE_RESULT;
|
||||
await patchRow(row.Id, {
|
||||
scrape_status: 'scraped',
|
||||
scrape_content: scrapeContent
|
||||
scrape_content: JSON.stringify(scrapeData),
|
||||
});
|
||||
} catch (e) {
|
||||
log(`Scrape échoué: ${e.message}`);
|
||||
await nocodbPatch(NOCODB_TABLE_ORGAS, row.Id, { scrape_status: 'failed' });
|
||||
// Continue avec l'IA sans contenu scrape
|
||||
await patchRow(row.Id, { scrape_status: 'failed' });
|
||||
}
|
||||
} else if (!hasUrl) {
|
||||
await nocodbPatch(NOCODB_TABLE_ORGAS, row.Id, { scrape_status: 'no_link' });
|
||||
} else if (!primaryUrl) {
|
||||
await patchRow(row.Id, { scrape_status: 'no_link' });
|
||||
}
|
||||
|
||||
// Appel Mistral Nemo
|
||||
const enriched = await callMistralWithRetry(row, scrapeContent);
|
||||
// Appel LLM (Bifrost)
|
||||
const enriched = useNetwork
|
||||
? await callBifrostWithRetry(row, scrapeData, autresLiens)
|
||||
: MOCK_BIFROST_RESULT;
|
||||
|
||||
if (!enriched) {
|
||||
log(`Échec Mistral sur fiche ${row.Id}, flag ai_error`);
|
||||
await nocodbPatch(NOCODB_TABLE_ORGAS, row.Id, {
|
||||
moderation_status: 'ai_error',
|
||||
ai_processed: true
|
||||
});
|
||||
log(`Échec Bifrost sur fiche ${row.Id}, flag ai_error`);
|
||||
await patchRow(row.Id, { moderation_status: 'ai_error', ai_processed: true });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Normalisation tags
|
||||
const rawTags = enriched.tags_fonction || [];
|
||||
const rawTags = enriched.tags || enriched.tags_fonction || [];
|
||||
const normalizedTags = [...new Set(rawTags.map(normalizeTag).filter(Boolean))];
|
||||
|
||||
// Update NocoDB
|
||||
const updateData = {
|
||||
description_enrichie: enriched.description_enrichie || null,
|
||||
points_cles: enriched.points_cles ? JSON.stringify(enriched.points_cles) : null,
|
||||
description_enrichie: enriched.description || null,
|
||||
tags_fonction: normalizedTags.join(','),
|
||||
moderation_status: 'ai_processed',
|
||||
ai_processed: true,
|
||||
ai_raw_output: JSON.stringify({ output: enriched, confiance: enriched.confiance })
|
||||
ai_raw_output: JSON.stringify({ output: enriched, confiance: enriched.confiance }),
|
||||
};
|
||||
|
||||
// Conserver echelle/territoire/localisation si l'IA les a enrichis
|
||||
if (enriched.echelle && !row.echelle) updateData.echelle = enriched.echelle;
|
||||
if (enriched.territoire && !row.territoire) updateData.territoire = enriched.territoire;
|
||||
if (enriched.localisation_ville && !row.localisation_ville) {
|
||||
updateData.localisation_ville = enriched.localisation_ville;
|
||||
// Nom : ne remplacer que le placeholder posé par le formulaire assoupli (B5-M1).
|
||||
if (enriched.nom && typeof row.nom === 'string' && row.nom.startsWith('[à qualifier]')) {
|
||||
updateData.nom = enriched.nom;
|
||||
}
|
||||
if (enriched.ville && !row.localisation_ville) {
|
||||
updateData.localisation_ville = enriched.ville;
|
||||
}
|
||||
// submission_type : ne réajuster que si l'utilisateur n'avait pas choisi de chip
|
||||
// (marqueur posé par utils/submitLibre.ts::toOrgaPayload — "Type : non précisé").
|
||||
const typeNonPrecise = typeof row.description_user === 'string'
|
||||
&& row.description_user.startsWith('Type : non précisé');
|
||||
if (typeNonPrecise && VALID_SUBMISSION_TYPES.includes(enriched.type_suggere)) {
|
||||
updateData.submission_type = enriched.type_suggere;
|
||||
}
|
||||
|
||||
await nocodbPatch(NOCODB_TABLE_ORGAS, row.Id, updateData);
|
||||
await patchRow(row.Id, updateData);
|
||||
await logUsage(enriched._usage, WORKER_MODEL, 'enrichissement', row.Id);
|
||||
|
||||
// Log usage tokens
|
||||
await logUsage(enriched._usage, 'open-mistral-nemo', 'enrichissement', row.Id);
|
||||
await notifyNtfy(
|
||||
'[AEP] Fiche enrichie',
|
||||
[
|
||||
`Id NocoDB : ${row.Id}`,
|
||||
`Nom suggéré : ${updateData.nom || row.nom}`,
|
||||
`Type : ${updateData.submission_type || row.submission_type || 'nc'}`,
|
||||
`Confiance : ${enriched.confiance || 'nc'}`,
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
const elapsed = ((Date.now() - rowStart) / 1000).toFixed(1);
|
||||
log(`Fiche ${row.Id} traitée en ${elapsed}s — confiance: ${enriched.confiance || 'nc'}`);
|
||||
@@ -480,15 +585,12 @@ async function run() {
|
||||
|
||||
log(`=== Run terminé: ${processedCount}/${rows.length} fiches traitées en ${((Date.now() - startTime) / 1000).toFixed(1)}s ===`);
|
||||
|
||||
// Check seuil modération
|
||||
await checkModerationQueue();
|
||||
|
||||
} catch (e) {
|
||||
log('ERREUR WORKER:', e.message);
|
||||
console.error(e.stack);
|
||||
process.exit(1);
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
releaseLock();
|
||||
if (!DRY_RUN) releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
[
|
||||
{
|
||||
"Id": 9001,
|
||||
"nom": "[à qualifier] exemple.org",
|
||||
"url": "https://exemple.org",
|
||||
"description_user": "Ça m'a beaucoup aidé à sortir de l'isolement quand j'ai monté mon agence.\n\nLiens :\nhttps://exemple.org\nhttps://exemple.org/guide-installation",
|
||||
"submitted_by_email": null,
|
||||
"submission_type": "ecosysteme",
|
||||
"moderation_status": "pending",
|
||||
"ai_processed": false,
|
||||
"scrape_status": "pending",
|
||||
"localisation_ville": null
|
||||
},
|
||||
{
|
||||
"Id": 9002,
|
||||
"nom": "[à qualifier] sans titre",
|
||||
"url": null,
|
||||
"description_user": "Type : non précisé\n\nUn super groupe d'entraide entre archis installés en rural, très actif sur les questions de reconversion.",
|
||||
"submitted_by_email": "test@exemple.fr",
|
||||
"submission_type": "ecosysteme",
|
||||
"moderation_status": "pending",
|
||||
"ai_processed": false,
|
||||
"scrape_status": "no_link",
|
||||
"localisation_ville": null
|
||||
}
|
||||
]
|
||||
Reference in New Issue
Block a user