Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
695cc8bbc2 | ||
|
|
09d1dc9d5f | ||
|
|
58937a04a3 | ||
|
|
38818f77ae | ||
|
|
d9f934a8b2 | ||
|
|
baa602e5f8 | ||
|
|
6087b8dd31 | ||
|
|
13af3fa1c5 | ||
|
|
1838f4b02d | ||
|
|
5b504b3e04 | ||
|
|
b2b8c13fee | ||
|
|
4b1340f160 | ||
|
|
0903b8989b | ||
|
|
b5aca80bbc | ||
|
|
946ed53aba | ||
|
|
d06c09a476 | ||
|
|
4291fe7529 |
@@ -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.
|
||||||
@@ -330,3 +330,54 @@ Pattern à généraliser :
|
|||||||
4. Normalize(tags) → taxonomie contrôlée
|
4. Normalize(tags) → taxonomie contrôlée
|
||||||
5. Log(usage) → stats_usage
|
5. Log(usage) → stats_usage
|
||||||
```
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. B5 (2026-09-27) — Formulaire assoupli + pipe adapté (fusion, pas réécriture)
|
||||||
|
|
||||||
|
Ce qui suit documente les écarts posés par le batch B5 (décisions de la
|
||||||
|
maîtrise d'œuvre du 27/09). Les sections 1 à 10 ci-dessus restent la
|
||||||
|
référence de fond (taxonomie, système prompt d'origine, circuit breaker) ;
|
||||||
|
cette section documente ce qui a changé dans `worker/enrich.js` et le pipe
|
||||||
|
de soumission (`server/api/submit/`).
|
||||||
|
|
||||||
|
### 11.1 Mapping soumission libre → colonnes NocoDB existantes (M1)
|
||||||
|
|
||||||
|
Le formulaire `/proposer` assoupli (`components/FormLibre.vue`,
|
||||||
|
`utils/submitLibre.ts`) écrit dans la table orgas existante, **aucune colonne
|
||||||
|
nouvelle** :
|
||||||
|
|
||||||
|
| Champ formulaire | Colonne NocoDB | Règle |
|
||||||
|
|---|---|---|
|
||||||
|
| 1er lien de la liste | `url` | `null` si aucun lien |
|
||||||
|
| texte « pourquoi » + tous les liens | `description_user` | `{texte}\n\nLiens :\n{lien1}\n{lien2}...` — et si aucun type suggéré, la ligne `Type : non précisé` est préfixée en tête |
|
||||||
|
| — | `nom` | hostname du 1er lien, ou 60 premiers caractères du texte si aucun lien, préfixé `[à qualifier] ` |
|
||||||
|
| chip type suggéré | `submission_type` | valeur choisie (`ecosysteme`/`reseau`/`job`/`outil`) ; si aucune chip → `ecosysteme` par défaut (voir ligne `Type : non précisé` ci-dessus) |
|
||||||
|
| chip « Références » | *(table `ressources_references`, pas orgas)* | `titre`/`auteur` placeholders, `description` = texte + liens, comme les autres |
|
||||||
|
| email (optionnel) | `submitted_by_email` | — |
|
||||||
|
|
||||||
|
**À faire au déploiement** : ajouter l'option `libre` à la liste de valeurs
|
||||||
|
acceptées par `submission_type` si l'on veut un jour distinguer une
|
||||||
|
soumission libre d'une soumission via un des 5 formulaires détaillés — non
|
||||||
|
fait ici pour ne pas tester un ALTER contre la prod sans l'avoir vérifié.
|
||||||
|
|
||||||
|
### 11.2 Worker — écarts (M2)
|
||||||
|
|
||||||
|
| Sujet | NAV V2 (§1-10 ci-dessus) | AEP B5 |
|
||||||
|
|---|---|---|
|
||||||
|
| LLM | Mistral Nemo, appel direct | **Bifrost** (`${BIFROST_URL}/v1/chat/completions`, header `x-bf-vk`), modèle `WORKER_MODEL` (défaut `groq/llama-3.1-8b-instant`) |
|
||||||
|
| 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).
|
||||||
|
|||||||
@@ -100,6 +100,13 @@
|
|||||||
|
|
||||||
<!-- Actions droite -->
|
<!-- Actions droite -->
|
||||||
<div class="flex items-center gap-2 shrink-0">
|
<div class="flex items-center gap-2 shrink-0">
|
||||||
|
<NuxtLink
|
||||||
|
to="/manifeste"
|
||||||
|
class="px-3 py-1.5 rounded-lg text-sm font-medium transition-all hover:opacity-80 hidden md:inline-flex items-center gap-1"
|
||||||
|
:style="route.path === '/manifeste' ? 'color: var(--nav-primary-solid); font-weight: 700;' : 'color: var(--nav-text-muted);'"
|
||||||
|
>
|
||||||
|
Manifeste
|
||||||
|
</NuxtLink>
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
to="/a-propos"
|
to="/a-propos"
|
||||||
class="px-3 py-1.5 rounded-lg text-sm font-medium transition-all hover:opacity-80 hidden md:inline-flex items-center gap-1"
|
class="px-3 py-1.5 rounded-lg text-sm font-medium transition-all hover:opacity-80 hidden md:inline-flex items-center gap-1"
|
||||||
@@ -156,8 +163,8 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
|
|
||||||
<!-- Hamburger mobile (lg:hidden) — toujours en dernier à droite -->
|
<!-- Menu déroulant — toutes largeurs (les onglets n'y apparaissent qu'en dessous de 1024px) ; porte « Soutenir » -->
|
||||||
<div class="lg:hidden relative">
|
<div class="relative">
|
||||||
<button
|
<button
|
||||||
@click="hamburgerOpen = !hamburgerOpen"
|
@click="hamburgerOpen = !hamburgerOpen"
|
||||||
class="p-2 rounded-lg transition-all hover:opacity-80"
|
class="p-2 rounded-lg transition-all hover:opacity-80"
|
||||||
@@ -178,16 +185,16 @@
|
|||||||
style="background: var(--nav-surface); border: 1px solid var(--nav-bg-alt); z-index: 9999;"
|
style="background: var(--nav-surface); border: 1px solid var(--nav-bg-alt); z-index: 9999;"
|
||||||
@click="hamburgerOpen = false"
|
@click="hamburgerOpen = false"
|
||||||
>
|
>
|
||||||
<NuxtLink to="/outils" class="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="/outils" class="lg:hidden block px-4 py-2.5 text-sm font-medium transition-opacity hover:opacity-70" :style="route.path === '/outils' ? 'color: var(--nav-primary-solid); font-weight: 700;' : 'color: var(--nav-text);'">Outils</NuxtLink>
|
||||||
<NuxtLink to="/" class="block px-4 py-2.5 text-sm font-medium transition-opacity hover:opacity-70" :style="route.path === '/' ? 'color: var(--nav-primary-solid); font-weight: 700;' : 'color: var(--nav-text);'">Écosystème Entraide Architecture</NuxtLink>
|
<NuxtLink to="/" class="lg:hidden block px-4 py-2.5 text-sm font-medium transition-opacity hover:opacity-70" :style="route.path === '/' ? 'color: var(--nav-primary-solid); font-weight: 700;' : 'color: var(--nav-text);'">Écosystème Entraide</NuxtLink>
|
||||||
<NuxtLink to="/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="/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="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="/trouver-du-taf" class="lg:hidden block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" :style="route.path === '/trouver-du-taf' ? 'color: var(--nav-primary-solid); font-weight: 700;' : 'color: var(--nav-text);'">Jobs</NuxtLink>
|
||||||
<NuxtLink to="/rag" class="block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" style="color: var(--nav-text);">RAG</NuxtLink>
|
<NuxtLink to="/media" class="lg:hidden block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" :style="route.path.startsWith('/media') ? 'color: var(--nav-primary-solid); font-weight: 700;' : 'color: var(--nav-text);'">recherche-média</NuxtLink>
|
||||||
<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>
|
<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 style="height: 1px; background: var(--nav-bg-alt); margin: 4px 0;"></div>
|
<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="/manifeste" class="block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" :style="route.path === '/manifeste' ? 'color: var(--nav-primary-solid); font-weight: 700;' : 'color: var(--nav-text-muted);'">Manifeste</NuxtLink>
|
||||||
<NuxtLink to="/a-propos" class="block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" style="color: var(--nav-text-muted);">À propos</NuxtLink>
|
<NuxtLink to="/a-propos" class="block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" style="color: var(--nav-text-muted);">À propos</NuxtLink>
|
||||||
<a href="https://liberapay.com/trans-former.fr/donate" target="_blank" rel="noopener noreferrer" class="block 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>
|
<NuxtLink to="/signaler" class="block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" style="color: var(--nav-text-muted);">Signaler</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -199,9 +206,6 @@
|
|||||||
<NuxtPage />
|
<NuxtPage />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Bandeau bas — transparence IA + soutien + compteurs semaine -->
|
|
||||||
<BandeauBas />
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
+32
-16
@@ -3,28 +3,44 @@
|
|||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
/* ── Palette NAV V2 — Sobre institutionnel ─────────────────────────────── */
|
/* ── Palette NAV V2 — Sobre institutionnel ─────────────────────────────── */
|
||||||
|
/* --nav-* sont désormais des ALIAS vers les rôles @transformer/ui (tokens.css
|
||||||
|
+ theme/aep.css, chargés avant ce fichier dans nuxt.config.ts).
|
||||||
|
--nav-primary-raw, --nav-primary-solid et --nav-text-on-primary restent des
|
||||||
|
valeurs PROPRES à nav-carte (non aliasées) :
|
||||||
|
- le DS n'a pas de triplet RGB (--nav-primary-raw) ;
|
||||||
|
- le --text du DS en sombre (#e8ecf5) ne correspond pas à la teinte bleu
|
||||||
|
nuit voulue ici pour --nav-primary-solid (#c8d2f0) ;
|
||||||
|
- --nav-text-on-primary sert de texte de contraste à la fois sur
|
||||||
|
--nav-primary (translucide, alias DS) ET sur --nav-primary-solid (opaque,
|
||||||
|
propre à nav-carte) — deux fonds dont la légèreté s'inverse en sombre
|
||||||
|
(translucide ≈ fond sombre, solid = lavande claire). Le rôle --on-primary
|
||||||
|
du DS ne colle qu'au premier cas ; l'aliaser cassait le contraste du AEP
|
||||||
|
logo, des boutons trouver-du-taf, BandeauBas, SimulateurFeature,
|
||||||
|
FicheDetail (texte clair sur --primary-solid clair en sombre — testé au
|
||||||
|
pixel le 25/09, illisible). Gardé littéral = identique à la prod avant B3.
|
||||||
|
Idem --nav-accent-soft, sans équivalent DS. (B3-M0, 2026-09-25) */
|
||||||
:root {
|
:root {
|
||||||
/* Fonds */
|
/* Fonds */
|
||||||
--nav-bg: #f8f6f1;
|
--nav-bg: var(--bg);
|
||||||
--nav-bg-alt: #eee9df;
|
--nav-bg-alt: var(--bg-alt);
|
||||||
--nav-surface: #ffffff;
|
--nav-surface: var(--surface);
|
||||||
|
|
||||||
/* Bleu nuit — 60% d'opacité sur les surfaces */
|
/* Bleu nuit — 60% d'opacité sur les surfaces */
|
||||||
--nav-primary-raw: 26, 34, 56;
|
--nav-primary-raw: 26, 34, 56;
|
||||||
--nav-primary: rgba(26, 34, 56, 0.6);
|
--nav-primary: var(--primary);
|
||||||
--nav-primary-solid: #1a2238;
|
--nav-primary-solid: #1a2238;
|
||||||
|
|
||||||
/* Accent safran */
|
/* Accent safran */
|
||||||
--nav-accent: #f5b342;
|
--nav-accent: var(--accent);
|
||||||
--nav-accent-soft: rgba(245, 179, 66, 0.85);
|
--nav-accent-soft: rgba(245, 179, 66, 0.85);
|
||||||
|
|
||||||
/* Texte */
|
/* Texte */
|
||||||
--nav-text: #1a2238;
|
--nav-text: var(--text);
|
||||||
--nav-text-muted: rgba(26, 34, 56, 0.55);
|
--nav-text-muted: var(--text-muted);
|
||||||
--nav-text-on-primary: #f8f6f1;
|
--nav-text-on-primary: #f8f6f1;
|
||||||
|
|
||||||
/* Typographie */
|
/* Typographie */
|
||||||
--nav-font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
--nav-font: var(--font-body);
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
@@ -58,25 +74,25 @@
|
|||||||
|
|
||||||
/* Focus ring accessibilité */
|
/* Focus ring accessibilité */
|
||||||
*:focus-visible {
|
*:focus-visible {
|
||||||
outline: 2px solid var(--nav-accent);
|
outline: 2px solid var(--focus-ring);
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Dark mode ──────────────────────────────────────────────────────────── */
|
/* ── Dark mode ──────────────────────────────────────────────────────────── */
|
||||||
.dark {
|
.dark {
|
||||||
--nav-bg: #111520;
|
--nav-bg: var(--bg);
|
||||||
--nav-bg-alt: #1e2538;
|
--nav-bg-alt: var(--bg-alt);
|
||||||
--nav-surface: #1a2238;
|
--nav-surface: var(--surface);
|
||||||
|
|
||||||
--nav-primary-raw: 200, 210, 240;
|
--nav-primary-raw: 200, 210, 240;
|
||||||
--nav-primary: rgba(200, 210, 240, 0.15);
|
--nav-primary: var(--primary);
|
||||||
--nav-primary-solid: #c8d2f0;
|
--nav-primary-solid: #c8d2f0;
|
||||||
|
|
||||||
--nav-accent: #f5b342;
|
--nav-accent: var(--accent);
|
||||||
--nav-accent-soft: rgba(245, 179, 66, 0.85);
|
--nav-accent-soft: rgba(245, 179, 66, 0.85);
|
||||||
|
|
||||||
--nav-text: #e8ecf5;
|
--nav-text: var(--text);
|
||||||
--nav-text-muted: rgba(200, 210, 240, 0.55);
|
--nav-text-muted: var(--text-muted);
|
||||||
--nav-text-on-primary: #111520;
|
--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>
|
|
||||||
@@ -54,7 +54,6 @@
|
|||||||
<div v-if="messages.length === 0" class="onboarding-bubble">
|
<div v-if="messages.length === 0" class="onboarding-bubble">
|
||||||
<p>Explore les 120 structures de la carte par la conversation. Je peux t'aider à trouver des collectifs, agences ou réseaux selon ta situation, ta pratique ou tes inspirations du moment.</p>
|
<p>Explore les 120 structures de la carte par la conversation. Je peux t'aider à trouver des collectifs, agences ou réseaux selon ta situation, ta pratique ou tes inspirations du moment.</p>
|
||||||
<p class="example">Exemple : "Je cherche des acteurs de la rénovation de maisons individuelles en France, plutôt en milieu rural, avec des approches biosourcées ou low-tech."</p>
|
<p class="example">Exemple : "Je cherche des acteurs de la rénovation de maisons individuelles en France, plutôt en milieu rural, avec des approches biosourcées ou low-tech."</p>
|
||||||
<p style="margin-top: 8px; font-size: 0.72rem; opacity: 0.6;">Propulsé par Mistral FR - serveur européen souverain, zéro rétention.</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Messages -->
|
<!-- Messages -->
|
||||||
|
|||||||
@@ -69,7 +69,6 @@
|
|||||||
<div ref="messagesContainer" class="flex-1 overflow-y-auto px-4 py-4 flex flex-col gap-3">
|
<div ref="messagesContainer" class="flex-1 overflow-y-auto px-4 py-4 flex flex-col gap-3">
|
||||||
<!-- Message onboarding (avant la première question) -->
|
<!-- Message onboarding (avant la première question) -->
|
||||||
<div v-if="messages.length === 0" class="onboarding-bubble">
|
<div v-if="messages.length === 0" class="onboarding-bubble">
|
||||||
<p>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>
|
<p>Pour m'aider à te répondre efficacement, formule ta requête ainsi :</p>
|
||||||
<ul>
|
<ul>
|
||||||
<li>• Besoin : [ce que tu cherches]</li>
|
<li>• Besoin : [ce que tu cherches]</li>
|
||||||
|
|||||||
@@ -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,39 +1,16 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
<p class="text-xs font-bold uppercase tracking-widest" style="color: var(--nav-text-muted);">Échelle</p>
|
<p class="text-xs font-bold uppercase tracking-widest" style="color: var(--nav-text-muted);">Échelle</p>
|
||||||
<!-- Inline sur 1 ligne — même pattern que FonctionFilter -->
|
<ChipGroup aria-label="Filtrer par échelle">
|
||||||
<div class="flex flex-wrap gap-x-4 gap-y-1.5">
|
<Chip
|
||||||
<label
|
|
||||||
v-for="option in ECHELLES"
|
v-for="option in ECHELLES"
|
||||||
:key="option"
|
:key="option"
|
||||||
class="flex items-center gap-1.5 cursor-pointer select-none transition-opacity"
|
:label="option"
|
||||||
>
|
variant="echelle"
|
||||||
<!-- Case carrée -->
|
:active="isSelected(option)"
|
||||||
<span
|
@toggle="toggle(option)"
|
||||||
class="flex items-center justify-center shrink-0 transition-all"
|
|
||||||
style="width: 18px; height: 18px; border: 1.5px solid; border-radius: 3px;"
|
|
||||||
:style="isSelected(option)
|
|
||||||
? 'background: var(--nav-primary); border-color: var(--nav-primary); color: #ffffff;'
|
|
||||||
: 'background: var(--nav-bg-alt); border-color: rgba(26,34,56,0.25); color: transparent;'"
|
|
||||||
>
|
|
||||||
<svg v-if="isSelected(option)" width="11" height="11" viewBox="0 0 12 12" fill="none">
|
|
||||||
<polyline points="2,6 5,9 10,3" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
|
||||||
</svg>
|
|
||||||
</span>
|
|
||||||
<!-- Label -->
|
|
||||||
<span
|
|
||||||
class="text-sm leading-tight"
|
|
||||||
:style="isSelected(option) ? 'color: var(--nav-text); font-weight: 600;' : 'color: var(--nav-text);'"
|
|
||||||
>{{ option }}</span>
|
|
||||||
<!-- Input réel (masqué) -->
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
class="sr-only"
|
|
||||||
:checked="isSelected(option)"
|
|
||||||
@change="toggle(option)"
|
|
||||||
/>
|
/>
|
||||||
</label>
|
</ChipGroup>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -183,6 +183,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
const baseTiles = useBaseTiles()
|
||||||
import type { Org } from '~/types/org'
|
import type { Org } from '~/types/org'
|
||||||
|
|
||||||
const props = defineProps<{ org: Org }>()
|
const props = defineProps<{ org: Org }>()
|
||||||
@@ -283,7 +284,7 @@ onMounted(async () => {
|
|||||||
attributionControl: false,
|
attributionControl: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
L.tileLayer(baseTiles.url(document.documentElement.classList.contains('dark')), {
|
||||||
maxZoom: 18,
|
maxZoom: 18,
|
||||||
}).addTo(map)
|
}).addTo(map)
|
||||||
|
|
||||||
|
|||||||
@@ -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,32 +1,17 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
<p class="text-xs font-bold uppercase tracking-widest" style="color: var(--nav-text-muted);">Fonction</p>
|
<p class="text-xs font-bold uppercase tracking-widest" style="color: var(--nav-text-muted);">Fonction</p>
|
||||||
<div class="space-y-1">
|
<ChipGroup aria-label="Filtrer par fonction">
|
||||||
<button
|
<Chip
|
||||||
v-for="fn in FONCTIONS"
|
v-for="fn in FONCTIONS"
|
||||||
:key="fn"
|
:key="fn"
|
||||||
@click="toggle(fn)"
|
:label="fn"
|
||||||
:aria-pressed="modelValue.includes(fn)"
|
variant="fonction"
|
||||||
class="flex items-center gap-2.5 w-full rounded px-1 py-0.5 transition-all text-left hover:opacity-80"
|
:active="modelValue.includes(fn)"
|
||||||
:style="modelValue.includes(fn) ? 'background: rgba(26,34,56,0.06);' : ''"
|
:count="modelValue.includes(fn) ? (modelValue.indexOf(fn) + 1) : (counts[fn] ?? 0)"
|
||||||
>
|
@toggle="toggle(fn)"
|
||||||
<!-- Case : affiche le rang de priorité si actif, sinon le nombre d'orgs -->
|
/>
|
||||||
<span
|
</ChipGroup>
|
||||||
class="flex items-center justify-center shrink-0 text-xs font-bold transition-all"
|
|
||||||
style="width: 24px; height: 24px; border: 1.5px solid; border-radius: 4px;"
|
|
||||||
:style="modelValue.includes(fn)
|
|
||||||
? 'background: var(--nav-primary); border-color: var(--nav-primary); color: var(--nav-text-on-primary);'
|
|
||||||
: 'background: var(--nav-bg-alt); border-color: var(--nav-bg-alt); color: var(--nav-text-muted);'"
|
|
||||||
>
|
|
||||||
{{ modelValue.includes(fn) ? (modelValue.indexOf(fn) + 1) : (counts[fn] ?? 0) }}
|
|
||||||
</span>
|
|
||||||
<!-- Label -->
|
|
||||||
<span
|
|
||||||
class="text-sm leading-tight"
|
|
||||||
:style="modelValue.includes(fn) ? 'color: var(--nav-text); font-weight: 600;' : 'color: var(--nav-text);'"
|
|
||||||
>{{ fn }}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<p v-if="modelValue.length" class="text-xs pt-0.5" style="color: var(--nav-text-muted);">
|
<p v-if="modelValue.length" class="text-xs pt-0.5" style="color: var(--nav-text-muted);">
|
||||||
{{ modelValue.length }} actif{{ modelValue.length > 1 ? 's' : '' }}
|
{{ modelValue.length }} actif{{ modelValue.length > 1 ? 's' : '' }}
|
||||||
<button @click="emit('update:modelValue', [])" class="ml-2 underline hover:opacity-70">Effacer</button>
|
<button @click="emit('update:modelValue', [])" class="ml-2 underline hover:opacity-70">Effacer</button>
|
||||||
|
|||||||
@@ -0,0 +1,240 @@
|
|||||||
|
<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 — ça nous aide à trier)</span></label>
|
||||||
|
<ChipGroup aria-label="Type suggéré">
|
||||||
|
<Chip
|
||||||
|
v-for="t in TYPES_SUGGERES"
|
||||||
|
:key="t"
|
||||||
|
:label="TYPES_SUGGERES_LABELS[t]"
|
||||||
|
variant="fonction"
|
||||||
|
:active="typeSuggere === t"
|
||||||
|
@toggle="toggleType(t)"
|
||||||
|
/>
|
||||||
|
</ChipGroup>
|
||||||
|
</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, 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)
|
||||||
|
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('')
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleType(t: TypeSuggere) {
|
||||||
|
typeSuggere.value = typeSuggere.value === t ? undefined : 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,
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="hashtag-filter" style="padding: 8px 12px; background: var(--nav-surface);">
|
<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;">
|
<div style="margin-bottom: 6px;">
|
||||||
<span class="filter-label">FAMILLES</span>
|
<span class="filter-label">FAMILLES</span>
|
||||||
<div class="chips-row">
|
<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;"
|
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>
|
>{{ hashtagsVisible ? 'Replier' : 'Afficher (' + props.allHashtags.length + ')' }}</button>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="hashtagsVisible" class="chips-row">
|
<ChipGroup v-if="hashtagsVisible" aria-label="Filtrer par hashtag">
|
||||||
<span
|
<Chip
|
||||||
v-for="tag in props.allHashtags"
|
v-for="tag in props.allHashtags"
|
||||||
:key="tag"
|
:key="tag"
|
||||||
class="chip chip-small"
|
:label="tag"
|
||||||
:style="selectedHashtags.includes(tag)
|
variant="fonction"
|
||||||
? 'background: var(--nav-primary); color: var(--nav-text-on-primary); font-weight: 600;'
|
:active="selectedHashtags.includes(tag)"
|
||||||
: 'background: var(--nav-bg-alt); color: var(--nav-text-muted);'"
|
@toggle="toggleHashtag(tag)"
|
||||||
@click="toggleHashtag(tag)"
|
/>
|
||||||
>{{ tag }}</span>
|
</ChipGroup>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -93,5 +92,4 @@ function toggleHashtag(tag: string) {
|
|||||||
transition: all 0.15s;
|
transition: all 0.15s;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
.chip-small { font-size: 0.7rem; padding: 2px 8px; }
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -31,7 +31,7 @@
|
|||||||
<p style="font-size: 0.875rem; line-height: 1.6; opacity: 0.75; margin: 0 0 24px 0;">
|
<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 :
|
Elle ne prétend pas à l'exhaustivité. Elle est un geste politique :
|
||||||
rendre visible ce qui se transforme, comment, par qui, où.
|
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>
|
</p>
|
||||||
<button
|
<button
|
||||||
@click="dismiss"
|
@click="dismiss"
|
||||||
|
|||||||
@@ -263,10 +263,10 @@
|
|||||||
<Transition name="modal">
|
<Transition name="modal">
|
||||||
<div v-if="ragInfoOpen" class="fixed z-[2001] left-1/2 flex flex-col"
|
<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;"
|
style="top:50%;transform:translate(-50%,-50%);width:min(580px,94vw);max-height:85vh;background:var(--nav-bg);border-radius:14px;box-shadow:0 16px 64px rgba(26,34,56,0.28);overflow:hidden;"
|
||||||
role="dialog" aria-modal="true" aria-label="A propos du RAG FRACAS">
|
role="dialog" aria-modal="true" aria-label="À propos du RAG FRACAS">
|
||||||
<div class="flex items-center justify-between px-5 py-4 shrink-0"
|
<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);">
|
style="border-bottom:2px solid var(--nav-bg-alt);background:var(--nav-surface);">
|
||||||
<h2 class="font-bold text-base" style="color:var(--nav-text);">FRACAS - Bibliotheque des pensees ecologiques</h2>
|
<h2 class="font-bold text-base" style="color:var(--nav-text);">FRACAS - Bibliothèque des pensées écologiques</h2>
|
||||||
<button @click="ragInfoOpen = false" class="ml-3 shrink-0 flex items-center justify-center w-8 h-8 rounded-full hover:opacity-70"
|
<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">
|
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">
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
|
||||||
@@ -275,8 +275,9 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</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;">
|
<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>
|
<EchelleTete echelle="XL" compact @navigate="ragInfoOpen = false" />
|
||||||
<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>
|
<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="flex flex-col gap-3">
|
||||||
<div class="p-3 rounded-lg" style="background:var(--nav-bg-alt);">
|
<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 class="font-semibold mb-1" style="font-size:0.8rem;color:var(--nav-text-muted);text-transform:uppercase;letter-spacing:0.05em;">Ce qu'est un RAG</p>
|
||||||
|
|||||||
@@ -32,6 +32,7 @@
|
|||||||
|
|
||||||
<div class="mission-body">
|
<div class="mission-body">
|
||||||
<h2 id="mission-title" class="mission-title">{{ title }}</h2>
|
<h2 id="mission-title" class="mission-title">{{ title }}</h2>
|
||||||
|
<EchelleTete v-if="echelle" :echelle="echelle" compact @navigate="close" />
|
||||||
<slot>
|
<slot>
|
||||||
<p class="mission-text">
|
<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é.
|
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>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import type { EchelleId } from '~/utils/echelles'
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
modelValue: boolean
|
modelValue: boolean
|
||||||
title?: string
|
title?: string
|
||||||
ctaLabel?: string
|
ctaLabel?: string
|
||||||
storageKey?: 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",
|
title: "L'écosystème d'entraide architecte",
|
||||||
ctaLabel: 'Explorer la carte',
|
ctaLabel: 'Explorer la carte',
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
const baseTiles = useBaseTiles()
|
||||||
import type { Map, Marker, DivIcon } from 'leaflet'
|
import type { Map, Marker, DivIcon } from 'leaflet'
|
||||||
|
|
||||||
interface Org {
|
interface Org {
|
||||||
@@ -90,12 +91,10 @@ async function initMap() {
|
|||||||
|
|
||||||
// Fond de carte CartoDB Positron (light ou dark selon theme)
|
// Fond de carte CartoDB Positron (light ou dark selon theme)
|
||||||
const isDark = typeof document !== 'undefined' && document.documentElement.classList.contains('dark')
|
const isDark = typeof document !== 'undefined' && document.documentElement.classList.contains('dark')
|
||||||
const tileUrl = isDark
|
const tileUrl = baseTiles.url(isDark)
|
||||||
? 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'
|
|
||||||
: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png'
|
|
||||||
|
|
||||||
tileLayerInstance = L.tileLayer(tileUrl, {
|
tileLayerInstance = L.tileLayer(tileUrl, {
|
||||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors © <a href="https://carto.com/attributions">CARTO</a>',
|
attribution: baseTiles.attribution,
|
||||||
maxZoom: 19,
|
maxZoom: 19,
|
||||||
})
|
})
|
||||||
tileLayerInstance.addTo(mapInstance!)
|
tileLayerInstance.addTo(mapInstance!)
|
||||||
@@ -212,9 +211,7 @@ function updateTileTheme(dark: boolean) {
|
|||||||
if (!mapInstance || !tileLayerInstance) return
|
if (!mapInstance || !tileLayerInstance) return
|
||||||
const L = (window as any).L
|
const L = (window as any).L
|
||||||
if (!L) return
|
if (!L) return
|
||||||
const url = dark
|
const url = baseTiles.url(dark)
|
||||||
? 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'
|
|
||||||
: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png'
|
|
||||||
tileLayerInstance.setUrl(url)
|
tileLayerInstance.setUrl(url)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
const baseTiles = useBaseTiles()
|
||||||
import type { Map, Marker, DivIcon } from 'leaflet'
|
import type { Map, Marker, DivIcon } from 'leaflet'
|
||||||
import type { StructureV2 } from '~/types/structure-v2'
|
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 isDark = typeof document !== 'undefined' && document.documentElement.classList.contains('dark')
|
||||||
const tileUrl = isDark
|
const tileUrl = baseTiles.url(isDark)
|
||||||
? 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'
|
|
||||||
: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png'
|
|
||||||
|
|
||||||
tileLayerInstance = L.tileLayer(tileUrl, {
|
tileLayerInstance = L.tileLayer(tileUrl, {
|
||||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors © <a href="https://carto.com/attributions">CARTO</a>',
|
attribution: baseTiles.attribution,
|
||||||
maxZoom: 19,
|
maxZoom: 19,
|
||||||
})
|
})
|
||||||
tileLayerInstance.addTo(mapInstance!)
|
tileLayerInstance.addTo(mapInstance!)
|
||||||
@@ -213,9 +212,7 @@ function updateTileTheme(dark: boolean) {
|
|||||||
if (!mapInstance || !tileLayerInstance) return
|
if (!mapInstance || !tileLayerInstance) return
|
||||||
const L = (window as any).L
|
const L = (window as any).L
|
||||||
if (!L) return
|
if (!L) return
|
||||||
const url = dark
|
const url = baseTiles.url(dark)
|
||||||
? 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'
|
|
||||||
: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png'
|
|
||||||
tileLayerInstance.setUrl(url)
|
tileLayerInstance.setUrl(url)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -38,6 +38,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
const baseTiles = useBaseTiles()
|
||||||
import type { Map as LeafletMap, TileLayer } from 'leaflet'
|
import type { Map as LeafletMap, TileLayer } from 'leaflet'
|
||||||
|
|
||||||
interface Org {
|
interface Org {
|
||||||
@@ -111,9 +112,7 @@ function createPinIcon(L: any, isPrioritaire: boolean, isSelected = false) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getTileUrl(dark: boolean) {
|
function getTileUrl(dark: boolean) {
|
||||||
return dark
|
return baseTiles.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'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function initSingleMap(domName: string) {
|
async function initSingleMap(domName: string) {
|
||||||
@@ -132,7 +131,7 @@ async function initSingleMap(domName: string) {
|
|||||||
touchZoom: true, keyboard: false,
|
touchZoom: true, keyboard: false,
|
||||||
})
|
})
|
||||||
const tileLayer = L.tileLayer(getTileUrl(isDark), {
|
const tileLayer = L.tileLayer(getTileUrl(isDark), {
|
||||||
attribution: '© OpenStreetMap contributors © CARTO', maxZoom: 19,
|
attribution: baseTiles.attribution, maxZoom: 19,
|
||||||
})
|
})
|
||||||
tileLayer.addTo(map)
|
tileLayer.addTo(map)
|
||||||
tileLayers[domName] = tileLayer as unknown as TileLayer
|
tileLayers[domName] = tileLayer as unknown as TileLayer
|
||||||
|
|||||||
@@ -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,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 }
|
||||||
|
}
|
||||||
@@ -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
|
||||||
+19
-1
@@ -1,6 +1,20 @@
|
|||||||
export default defineNuxtConfig({
|
export default defineNuxtConfig({
|
||||||
modules: ['@nuxtjs/tailwindcss'],
|
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: [
|
css: [
|
||||||
|
'@transformer/ui/tokens.css',
|
||||||
|
'@transformer/ui/theme/aep.css',
|
||||||
'~/assets/css/main.css',
|
'~/assets/css/main.css',
|
||||||
'leaflet/dist/leaflet.css',
|
'leaflet/dist/leaflet.css',
|
||||||
'leaflet.markercluster/dist/MarkerCluster.css',
|
'leaflet.markercluster/dist/MarkerCluster.css',
|
||||||
@@ -16,6 +30,8 @@ export default defineNuxtConfig({
|
|||||||
commentTableId: process.env.COMMENT_TABLE_ID || process.env.AVIS_TABLE_ID,
|
commentTableId: process.env.COMMENT_TABLE_ID || process.env.AVIS_TABLE_ID,
|
||||||
statsTableId: process.env.STATS_TABLE_ID || 'mbbq7n47ixy19mc',
|
statsTableId: process.env.STATS_TABLE_ID || 'mbbq7n47ixy19mc',
|
||||||
mistralApiKey: process.env.MISTRAL_API_KEY,
|
mistralApiKey: process.env.MISTRAL_API_KEY,
|
||||||
|
bifrostUrl: process.env.BIFROST_URL || 'http://127.0.0.1:8080',
|
||||||
|
bifrostVk: process.env.BIFROST_VK,
|
||||||
redisUrl: process.env.REDIS_URL || 'redis://127.0.0.1:6379',
|
redisUrl: process.env.REDIS_URL || 'redis://127.0.0.1:6379',
|
||||||
resendApiKey: process.env.RESEND_API_KEY,
|
resendApiKey: process.env.RESEND_API_KEY,
|
||||||
emailJules: process.env.EMAIL_JULES || 'jules@trans-former.fr',
|
emailJules: process.env.EMAIL_JULES || 'jules@trans-former.fr',
|
||||||
@@ -25,13 +41,15 @@ export default defineNuxtConfig({
|
|||||||
codevAdminPassword: 'admin2026', // NUXT_CODEV_ADMIN_PASSWORD
|
codevAdminPassword: 'admin2026', // NUXT_CODEV_ADMIN_PASSWORD
|
||||||
ragPeUrl: process.env.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 || '',
|
referencesTableId: process.env.REFERENCES_TABLE_ID || process.env.NUXT_REFERENCES_TABLE_ID || '',
|
||||||
|
public: {
|
||||||
|
cartoKey: '', // NUXT_PUBLIC_CARTO_KEY — clé CARTO Basemaps (gratuite), lue au runtime
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
// Leaflet ne fonctionne pas en SSR — forcer le rendu côté client
|
// Leaflet ne fonctionne pas en SSR — forcer le rendu côté client
|
||||||
ssr: true,
|
ssr: true,
|
||||||
|
|
||||||
vite: {
|
vite: {
|
||||||
cacheDir: 'C:/Users/jules/AppData/Local/nav-carte-vite-cache',
|
|
||||||
optimizeDeps: {
|
optimizeDeps: {
|
||||||
include: ['leaflet', 'leaflet.markercluster', 'd3'],
|
include: ['leaflet', 'leaflet.markercluster', 'd3'],
|
||||||
},
|
},
|
||||||
|
|||||||
Generated
+6
@@ -8,6 +8,7 @@
|
|||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@headlessui/vue": "^1.7.23",
|
"@headlessui/vue": "^1.7.23",
|
||||||
|
"@transformer/ui": "git+https://git.trans-former.fr/jules/design-system.git",
|
||||||
"@types/leaflet": "^1.9.21",
|
"@types/leaflet": "^1.9.21",
|
||||||
"@types/leaflet.markercluster": "^1.5.6",
|
"@types/leaflet.markercluster": "^1.5.6",
|
||||||
"d3": "^7.9.0",
|
"d3": "^7.9.0",
|
||||||
@@ -3625,6 +3626,11 @@
|
|||||||
"vue": "^2.7.0 || ^3.0.0"
|
"vue": "^2.7.0 || ^3.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@transformer/ui": {
|
||||||
|
"version": "0.1.0",
|
||||||
|
"resolved": "git+https://git.trans-former.fr/jules/design-system.git#057de406c804315e09d28fb26f10941d61e461b1",
|
||||||
|
"license": "UNLICENSED"
|
||||||
|
},
|
||||||
"node_modules/@tybys/wasm-util": {
|
"node_modules/@tybys/wasm-util": {
|
||||||
"version": "0.10.1",
|
"version": "0.10.1",
|
||||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
|
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@headlessui/vue": "^1.7.23",
|
"@headlessui/vue": "^1.7.23",
|
||||||
|
"@transformer/ui": "git+https://git.trans-former.fr/jules/design-system.git",
|
||||||
"@types/leaflet": "^1.9.21",
|
"@types/leaflet": "^1.9.21",
|
||||||
"@types/leaflet.markercluster": "^1.5.6",
|
"@types/leaflet.markercluster": "^1.5.6",
|
||||||
"d3": "^7.9.0",
|
"d3": "^7.9.0",
|
||||||
|
|||||||
+1
-66
@@ -13,7 +13,7 @@
|
|||||||
<section class="section-mission">
|
<section class="section-mission">
|
||||||
<h1>À propos d'AEP</h1>
|
<h1>À propos d'AEP</h1>
|
||||||
<p class="mission-text">
|
<p class="mission-text">
|
||||||
AEP — Architecture d'Écologie Politique — est un commun vivant : une infrastructure d'entraide, de ressources documentées et de cartographies au service d'une profession en mutation. Ce site rassemble 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>
|
</p>
|
||||||
</section>
|
</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.
|
Ce site tourne sur un VPS - un serveur privé loué en Europe - construit entièrement avec des logiciels open source, quasi gratuits. Pas de services Google, pas d'Amazon, pas de Microsoft dans la chaîne. C'est un choix délibéré : contrôler son infrastructure, c'est contrôler ses données.
|
||||||
</p>
|
</p>
|
||||||
<div class="badges-row">
|
<div class="badges-row">
|
||||||
<div class="badge">
|
|
||||||
<span class="badge-icon" aria-hidden="true">
|
|
||||||
<!-- Mistral logo placeholder -->
|
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 8v4l3 3"/></svg>
|
|
||||||
</span>
|
|
||||||
<span class="badge-label">IA souveraine</span>
|
|
||||||
<span class="badge-detail">Mistral AI, Paris - conforme RGPD ; pas de collecte ni de profiling ; aucun transfert hors UE</span>
|
|
||||||
</div>
|
|
||||||
<div class="badge">
|
<div class="badge">
|
||||||
<span class="badge-icon" aria-hidden="true">
|
<span class="badge-icon" aria-hidden="true">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>
|
||||||
@@ -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.
|
L'IA est un outil. La question, c'est au service de quoi. Oui, elle est énergivore - et il faut l'assumer. Mais si c'est au service de l'émancipation, du collectif, de transformer nos infrastructures sociétales, alors on dit oui - en sachant que c'est une étape transitoire. L'infrastructure de l'IA elle-même peut évoluer : devenir plus locale, plus sobre, plus souveraine. C'est dans cette direction qu'on regarde.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="ia-grid">
|
|
||||||
<div class="ia-card">
|
|
||||||
<div class="ia-card-label">Enrichissement des fiches</div>
|
|
||||||
<div class="ia-card-model">Mistral Nemo</div>
|
|
||||||
<div class="ia-card-cost">~0,00003 €/fiche</div>
|
|
||||||
</div>
|
|
||||||
<div class="ia-card">
|
|
||||||
<div class="ia-card-label">Chatbot de recherche</div>
|
|
||||||
<div class="ia-card-model">Mistral Small</div>
|
|
||||||
<div class="ia-card-cost">~0,003 €/requête</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ul class="ia-details">
|
<ul class="ia-details">
|
||||||
<li>10 € / mois investis au départ par le collectif - vos dons constituent la réserve pour faire vivre le projet et financer ses recherches</li>
|
<li>10 € / mois investis au départ par le collectif - vos dons constituent la réserve pour faire vivre le projet et financer ses recherches</li>
|
||||||
<li>Coût visible en direct dans le bandeau bas de la carte</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -398,49 +376,6 @@ function copyApiUrl() {
|
|||||||
|
|
||||||
/* ── IA grid ─────────────────────────────────────────────────────────────────── */
|
/* ── IA grid ─────────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
.ia-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
gap: 0.75rem;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
|
||||||
.ia-grid {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.ia-card {
|
|
||||||
background: var(--nav-surface);
|
|
||||||
border: 1px solid rgba(26, 34, 56, 0.1);
|
|
||||||
border-radius: 10px;
|
|
||||||
padding: 1rem 1.125rem;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ia-card-label {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
color: var(--nav-text-muted);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ia-card-model {
|
|
||||||
font-size: 1rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--nav-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ia-card-cost {
|
|
||||||
font-size: 0.825rem;
|
|
||||||
color: var(--nav-text-muted);
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ia-details {
|
.ia-details {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
|||||||
+110
-18
@@ -2,7 +2,15 @@
|
|||||||
<div class="flex h-full overflow-hidden" style="background: var(--nav-bg);">
|
<div class="flex h-full overflow-hidden" style="background: var(--nav-bg);">
|
||||||
|
|
||||||
<!-- ═══════════════════════════════════════ SIDEBAR DESKTOP (>= 1024px) -->
|
<!-- ═══════════════════════════════════════ 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 s'auto-affiche via Teleport (overlay plein ecran) -->
|
||||||
<IntentionBanner />
|
<IntentionBanner />
|
||||||
@@ -87,14 +95,15 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-0.5 text-xs" style="color: var(--nav-text-muted);">{{ structure.type_principal }} - {{ structure.ville }}</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">
|
<ChipGroup v-if="structure.hashtags.length" class="mt-1" :aria-label="`Hashtags de ${structure.nom}`">
|
||||||
<span
|
<Chip
|
||||||
v-for="tag in structure.hashtags.slice(0, 2)"
|
v-for="tag in structure.hashtags.slice(0, 2)"
|
||||||
:key="tag"
|
:key="tag"
|
||||||
class="text-xs"
|
as="span"
|
||||||
style="color: var(--nav-text-muted);"
|
variant="fonction"
|
||||||
>{{ tag }}</span>
|
:label="tag"
|
||||||
</div>
|
/>
|
||||||
|
</ChipGroup>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -112,22 +121,29 @@
|
|||||||
:style="desktopMapView === 'metropole'
|
:style="desktopMapView === 'metropole'
|
||||||
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
|
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
|
||||||
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
|
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
|
||||||
@click="desktopMapView = 'metropole'"
|
@click="setDesktopView('metropole')"
|
||||||
>Métropolitain</button>
|
>Métropolitain</button>
|
||||||
<button
|
<button
|
||||||
class="px-5 py-2 text-sm font-medium transition-colors"
|
class="px-5 py-2 text-sm font-medium transition-colors"
|
||||||
:style="desktopMapView === 'outremer'
|
:style="desktopMapView === 'outremer'
|
||||||
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
|
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
|
||||||
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
|
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
|
||||||
@click="desktopMapView = 'outremer'"
|
@click="setDesktopView('outremer')"
|
||||||
>Outre-mer</button>
|
>Outre-mer</button>
|
||||||
<button
|
<button
|
||||||
class="px-5 py-2 text-sm font-medium transition-colors"
|
class="px-5 py-2 text-sm font-medium transition-colors"
|
||||||
:style="desktopMapView === 'graphe'
|
:style="desktopMapView === 'graphe'
|
||||||
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
|
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
|
||||||
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
|
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
|
||||||
@click="desktopMapView = 'graphe'"
|
@click="setDesktopView('graphe')"
|
||||||
>Vue graphique</button>
|
>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>
|
</div>
|
||||||
|
|
||||||
<!-- Carte Métropole desktop -->
|
<!-- Carte Métropole desktop -->
|
||||||
@@ -194,31 +210,53 @@
|
|||||||
@applyHashtag="(tag) => { if (!selectedHashtags.includes(tag)) selectedHashtags = [...selectedHashtags, tag] }"
|
@applyHashtag="(tag) => { if (!selectedHashtags.includes(tag)) selectedHashtags = [...selectedHashtags, tag] }"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
</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);">
|
<div class="lg:hidden shrink-0 flex" style="background: var(--nav-surface); border-bottom: 1px solid var(--nav-bg-alt);">
|
||||||
<button
|
<button
|
||||||
class="flex-1 py-2 text-sm font-medium transition-colors"
|
class="flex-1 py-2 text-sm font-medium transition-colors"
|
||||||
:style="mobileMapView === 'metropole'
|
:style="mobileMapView === 'metropole'
|
||||||
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
|
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
|
||||||
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
|
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
|
||||||
@click="mobileMapView = 'metropole'"
|
@click="setMobileView('metropole')"
|
||||||
>Métropolitain</button>
|
>Métropolitain</button>
|
||||||
<button
|
<button
|
||||||
class="flex-1 py-2 text-sm font-medium transition-colors"
|
class="flex-1 py-2 text-sm font-medium transition-colors"
|
||||||
:style="mobileMapView === 'outremer'
|
:style="mobileMapView === 'outremer'
|
||||||
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
|
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
|
||||||
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
|
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
|
||||||
@click="mobileMapView = 'outremer'"
|
@click="setMobileView('outremer')"
|
||||||
>Outre-mer</button>
|
>Outre-mer</button>
|
||||||
<button
|
<button
|
||||||
class="flex-1 py-2 text-sm font-medium transition-colors"
|
class="flex-1 py-2 text-sm font-medium transition-colors"
|
||||||
:style="mobileMapView === 'graphe'
|
:style="mobileMapView === 'graphe'
|
||||||
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
|
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
|
||||||
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
|
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
|
||||||
@click="mobileMapView = 'graphe'"
|
@click="setMobileView('graphe')"
|
||||||
>Graphe</button>
|
>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>
|
||||||
|
|
||||||
<div class="lg:hidden flex-1 relative overflow-hidden">
|
<div class="lg:hidden flex-1 relative overflow-hidden">
|
||||||
@@ -272,8 +310,30 @@
|
|||||||
</ClientOnly>
|
</ClientOnly>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Bottom sheet swipable (masqué en vue graphique pour ne pas occulter le canvas) -->
|
<!-- Zone « Fiches » mobile (B3-M3) — pleine hauteur sous la barre d'onglets,
|
||||||
<ClientOnly v-if="mobileMapView !== 'graphe'">
|
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">
|
<MobileSheet :resultCount="filtered.length" :pending="pending">
|
||||||
<!-- Bandeau intention mobile -->
|
<!-- 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);">
|
<div class="px-3 py-2" style="background: var(--bifurc-banner-bg, #faf8f5); border-bottom: 1px solid var(--bifurc-banner-border, #e0d8cc);">
|
||||||
@@ -424,6 +484,7 @@
|
|||||||
title="Réseaux AEP — l'architecture qui s'engage"
|
title="Réseaux AEP — l'architecture qui s'engage"
|
||||||
ctaLabel="Explorer les 120 réseaux"
|
ctaLabel="Explorer les 120 réseaux"
|
||||||
storageKey="aep_reseaux_seen"
|
storageKey="aep_reseaux_seen"
|
||||||
|
echelle="M"
|
||||||
>
|
>
|
||||||
<p class="mission-text">
|
<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.
|
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.
|
||||||
@@ -454,15 +515,41 @@ function familleColor(f: number): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── État UI ────────────────────────────────────────────────────────────────
|
// ── État UI ────────────────────────────────────────────────────────────────
|
||||||
|
type MapView = 'metropole' | 'outremer' | 'graphe' | 'fiches'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
const selectedId = ref<string | null>(null)
|
const selectedId = ref<string | null>(null)
|
||||||
const hoveredId = ref<string | null>(null)
|
const hoveredId = ref<string | null>(null)
|
||||||
const ficheModalOpen = ref(false)
|
const ficheModalOpen = ref(false)
|
||||||
const ficheModalId = ref<string | null>(null)
|
const ficheModalId = ref<string | null>(null)
|
||||||
const chatbotOpen = ref(false)
|
const chatbotOpen = ref(false)
|
||||||
const mobileMapView = ref<'metropole' | 'outremer' | 'graphe'>('metropole')
|
const mobileMapView = ref<MapView>((route.query.vue as string) === 'fiches' ? 'fiches' : 'metropole')
|
||||||
const desktopMapView = ref<'metropole' | 'outremer' | 'graphe'>('metropole')
|
const desktopMapView = ref<MapView>((route.query.vue as string) === 'fiches' ? 'fiches' : 'metropole')
|
||||||
const missionOpen = ref(false)
|
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(() => {
|
onMounted(() => {
|
||||||
try {
|
try {
|
||||||
if (!localStorage.getItem('aep_reseaux_seen')) {
|
if (!localStorage.getItem('aep_reseaux_seen')) {
|
||||||
@@ -555,6 +642,11 @@ function resetFilters() {
|
|||||||
// Pour simplifier : toutes les structures (la carte gère les sans-coords)
|
// Pour simplifier : toutes les structures (la carte gère les sans-coords)
|
||||||
const metropoleStructures = computed<StructureV2[]>(() => filtered.value)
|
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
|
// 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
|
// OutremerMap attend le format Org legacy - on passe un tableau vide
|
||||||
const outremerOrgsLegacy = computed(() => [])
|
const outremerOrgsLegacy = computed(() => [])
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
<div class="lock-header">
|
<div class="lock-header">
|
||||||
<h1>Co-développement</h1>
|
<h1>Co-développement</h1>
|
||||||
<p class="lock-subtitle">Entraide entre pairs</p>
|
<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>
|
<p class="lock-intro">Cet espace est un cercle. Pour entrer, il y a un mot.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -104,6 +105,13 @@ async function submit() {
|
|||||||
margin: 0 0 1rem;
|
margin: 0 0 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.lock-echelle {
|
||||||
|
text-align: left;
|
||||||
|
padding: var(--space-md);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--bg-alt);
|
||||||
|
}
|
||||||
|
|
||||||
.lock-intro {
|
.lock-intro {
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
color: var(--nav-text-muted, #6b7280);
|
color: var(--nav-text-muted, #6b7280);
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
<!--
|
||||||
|
Page de dev — story de test FicheMiniCard (B3-M2)
|
||||||
|
Sert uniquement à la vérification visuelle. Aucun lien vers cette page
|
||||||
|
nulle part dans l'app (pas dans app.vue, pas dans les pages) : navigation
|
||||||
|
directe par URL uniquement (/dev/fiche-mini-card).
|
||||||
|
-->
|
||||||
|
<template>
|
||||||
|
<div class="story">
|
||||||
|
<h1 class="story__title">FicheMiniCard — story de test (B3-M2)</h1>
|
||||||
|
|
||||||
|
<section class="story__section">
|
||||||
|
<h2 class="story__heading">Org — cas nominal</h2>
|
||||||
|
<div class="story__grid">
|
||||||
|
<FicheMiniCard :fiche="orgNominal" @open="log('org-nominal')" />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="story__section">
|
||||||
|
<h2 class="story__heading">Structure — cas nominal</h2>
|
||||||
|
<div class="story__grid">
|
||||||
|
<FicheMiniCard :fiche="structureNominal" @open="log('structure-nominal')" />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="story__section">
|
||||||
|
<h2 class="story__heading">Cas limites</h2>
|
||||||
|
<div class="story__grid">
|
||||||
|
<FicheMiniCard :fiche="nomLong" @open="log('nom-long')" />
|
||||||
|
<FicheMiniCard :fiche="sansDescription" @open="log('sans-description')" />
|
||||||
|
<FicheMiniCard :fiche="sansChips" @open="log('sans-chips')" />
|
||||||
|
<FicheMiniCard :fiche="sansGeoloc" @open="log('sans-geoloc')" />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="story__section">
|
||||||
|
<h2 class="story__heading">Dernier événement « open »</h2>
|
||||||
|
<p class="story__log">{{ lastOpen ?? '(aucun clic pour le moment)' }}</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { MiniFiche } from '~/composables/useFicheAdapter'
|
||||||
|
|
||||||
|
definePageMeta({ layout: false })
|
||||||
|
|
||||||
|
const lastOpen = ref<string | null>(null)
|
||||||
|
function log(label: string) {
|
||||||
|
lastOpen.value = label
|
||||||
|
}
|
||||||
|
|
||||||
|
const orgNominal: MiniFiche = {
|
||||||
|
id: 1,
|
||||||
|
nom: 'Réseau Bâti Vivant',
|
||||||
|
resume: 'Un réseau national d\'accompagnement juridique et technique pour les collectifs d\'auto-construction.',
|
||||||
|
chips: ['Juridique', 'Technique'],
|
||||||
|
echelle: 'National',
|
||||||
|
geoloc: true,
|
||||||
|
kind: 'org',
|
||||||
|
}
|
||||||
|
|
||||||
|
const structureNominal: MiniFiche = {
|
||||||
|
id: 'f1-rotor',
|
||||||
|
nom: 'Rotor',
|
||||||
|
resume: 'Rotor invente en Belgique le protocole de la déconstruction sélective comme geste politique contre la culture du tout-neuf.',
|
||||||
|
chips: ['#reemploi-structurel', '#plateforme-materiaux'],
|
||||||
|
geoloc: true,
|
||||||
|
kind: 'structure',
|
||||||
|
}
|
||||||
|
|
||||||
|
const nomLong: MiniFiche = {
|
||||||
|
id: 2,
|
||||||
|
nom: 'Collectif Régional de Réemploi et d\'Accompagnement des Chantiers Participatifs du Grand Ouest',
|
||||||
|
resume: 'Nom volontairement long pour vérifier l\'ellipse sur une ligne.',
|
||||||
|
chips: ['Chantier'],
|
||||||
|
echelle: 'Régional',
|
||||||
|
geoloc: true,
|
||||||
|
kind: 'org',
|
||||||
|
}
|
||||||
|
|
||||||
|
const sansDescription: MiniFiche = {
|
||||||
|
id: 3,
|
||||||
|
nom: 'Structure sans description',
|
||||||
|
resume: '',
|
||||||
|
chips: ['Formation'],
|
||||||
|
echelle: 'Local',
|
||||||
|
geoloc: true,
|
||||||
|
kind: 'org',
|
||||||
|
}
|
||||||
|
|
||||||
|
const sansChips: MiniFiche = {
|
||||||
|
id: 4,
|
||||||
|
nom: 'Structure sans chips',
|
||||||
|
resume: 'Une organisation dont aucune fonction ni hashtag n\'est renseigné dans la source.',
|
||||||
|
chips: [],
|
||||||
|
geoloc: true,
|
||||||
|
kind: 'org',
|
||||||
|
}
|
||||||
|
|
||||||
|
const sansGeoloc: MiniFiche = {
|
||||||
|
id: 5,
|
||||||
|
nom: 'Structure sans localisation',
|
||||||
|
resume: 'Une fiche nationale sans latitude ni longitude — invisible sur la carte, visible ici.',
|
||||||
|
chips: ['Administratif', 'Comptabilité'],
|
||||||
|
echelle: 'National',
|
||||||
|
geoloc: false,
|
||||||
|
kind: 'org',
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.story {
|
||||||
|
max-width: 1100px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: var(--space-lg);
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: var(--font-body);
|
||||||
|
}
|
||||||
|
|
||||||
|
.story__title {
|
||||||
|
font-size: var(--fs-xl);
|
||||||
|
font-weight: var(--fw-bold);
|
||||||
|
margin-bottom: var(--space-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.story__section {
|
||||||
|
margin-bottom: var(--space-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.story__heading {
|
||||||
|
font-size: var(--fs-sm);
|
||||||
|
font-weight: var(--fw-semibold);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.story__grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||||
|
gap: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.story__log {
|
||||||
|
font-size: var(--fs-sm);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
+111
-30
@@ -40,7 +40,7 @@
|
|||||||
Mode dev — données seed
|
Mode dev — données seed
|
||||||
</div>
|
</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">
|
<div class="hidden lg:flex lg:flex-1 lg:flex-col lg:overflow-hidden">
|
||||||
<!-- Barre onglets desktop -->
|
<!-- Barre onglets desktop -->
|
||||||
<div class="shrink-0 flex" style="background: var(--nav-surface); border-bottom: 1px solid var(--nav-bg-alt);">
|
<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'
|
:style="desktopMapView === 'metropole'
|
||||||
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
|
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
|
||||||
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
|
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
|
||||||
@click="desktopMapView = 'metropole'"
|
@click="setDesktopView('metropole')"
|
||||||
>Métropolitain</button>
|
>Métropolitain</button>
|
||||||
<button
|
<button
|
||||||
class="px-5 py-2 text-sm font-medium transition-colors"
|
class="px-5 py-2 text-sm font-medium transition-colors"
|
||||||
:style="desktopMapView === 'outremer'
|
:style="desktopMapView === 'outremer'
|
||||||
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
|
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
|
||||||
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
|
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
|
||||||
@click="desktopMapView = 'outremer'"
|
@click="setDesktopView('outremer')"
|
||||||
>Outre-mer</button>
|
>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>
|
</div>
|
||||||
|
|
||||||
<!-- Carte Métropole desktop -->
|
<!-- Carte Métropole desktop -->
|
||||||
@@ -94,26 +101,58 @@
|
|||||||
</div>
|
</div>
|
||||||
<ChatbotPlaceholder @highlightOrgs="onHighlightOrgs" />
|
<ChatbotPlaceholder @highlightOrgs="onHighlightOrgs" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Panneau « Toutes les fiches » desktop (B3-M3) — remplace la carte,
|
||||||
|
fiches sans géoloc incluses (gain principal de cette mission) -->
|
||||||
|
<div v-show="desktopMapView === 'fiches'" class="flex-1 flex flex-col overflow-hidden">
|
||||||
|
<div
|
||||||
|
class="shrink-0 px-4 py-2"
|
||||||
|
style="border-bottom: 1px solid var(--nav-bg-alt); background: var(--nav-surface);"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="text-xs font-medium underline"
|
||||||
|
style="color: var(--nav-text-muted);"
|
||||||
|
@click="setDesktopView('metropole')"
|
||||||
|
>← Carte</button>
|
||||||
|
</div>
|
||||||
|
<FichesPanel
|
||||||
|
:fiches="allFichesMini"
|
||||||
|
:hashtags="[...FONCTIONS]"
|
||||||
|
:selected="fonctions"
|
||||||
|
:query="search"
|
||||||
|
intention="Toutes les structures d'entraide, y compris celles sans adresse sur la carte. Cochez une fonction pour filtrer."
|
||||||
|
@update:selected="onFonctions"
|
||||||
|
@open="(fiche) => onSelectOrg(Number(fiche.id))"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ── VUE MOBILE : Onglets Métro/Outre-mer + carte pleine hauteur + sheet swipable ── -->
|
<!-- ── VUE MOBILE : Onglets Métro/Outre-mer + carte pleine hauteur + sheet swipable ── -->
|
||||||
|
|
||||||
<!-- Onglets Métropolitain / Outre-mer -->
|
<!-- 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);">
|
<div class="lg:hidden shrink-0 flex" style="background: var(--nav-surface); border-bottom: 1px solid var(--nav-bg-alt);">
|
||||||
<button
|
<button
|
||||||
class="flex-1 py-2 text-sm font-medium transition-colors"
|
class="flex-1 py-2 text-sm font-medium transition-colors"
|
||||||
:style="mobileMapView === 'metropole'
|
:style="mobileMapView === 'metropole'
|
||||||
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
|
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
|
||||||
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
|
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
|
||||||
@click="mobileMapView = 'metropole'"
|
@click="setMobileView('metropole')"
|
||||||
>Métropolitain</button>
|
>Métropolitain</button>
|
||||||
<button
|
<button
|
||||||
class="flex-1 py-2 text-sm font-medium transition-colors"
|
class="flex-1 py-2 text-sm font-medium transition-colors"
|
||||||
:style="mobileMapView === 'outremer'
|
:style="mobileMapView === 'outremer'
|
||||||
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
|
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
|
||||||
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
|
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
|
||||||
@click="mobileMapView = 'outremer'"
|
@click="setMobileView('outremer')"
|
||||||
>Outre-mer</button>
|
>Outre-mer</button>
|
||||||
|
<button
|
||||||
|
class="flex-1 py-2 text-sm font-medium transition-colors"
|
||||||
|
:style="mobileMapView === 'fiches'
|
||||||
|
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
|
||||||
|
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
|
||||||
|
@click="setMobileView('fiches')"
|
||||||
|
>Fiches</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="lg:hidden flex-1 relative overflow-hidden">
|
<div class="lg:hidden flex-1 relative overflow-hidden">
|
||||||
@@ -154,8 +193,30 @@
|
|||||||
</ClientOnly>
|
</ClientOnly>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Bottom sheet swipable (Métropole et Outre-mer) -->
|
<!-- Zone « Fiches » mobile (B3-M3) — pleine hauteur sous la barre d'onglets,
|
||||||
<ClientOnly>
|
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">
|
<MobileSheet :resultCount="filtered.length" :pending="pending">
|
||||||
<!-- Barre recherche -->
|
<!-- Barre recherche -->
|
||||||
<div class="px-3 pt-2 pb-2" style="border-bottom: 1px solid var(--nav-bg-alt);">
|
<div class="px-3 pt-2 pb-2" style="border-bottom: 1px solid var(--nav-bg-alt);">
|
||||||
@@ -185,36 +246,36 @@
|
|||||||
</button>
|
</button>
|
||||||
</label>
|
</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">
|
<div class="mt-2">
|
||||||
<span class="text-xs font-bold uppercase tracking-wide block mb-1" style="color: var(--nav-text-muted);">ÉCHELLE</span>
|
<span class="text-xs font-bold uppercase tracking-wide block mb-1" style="color: var(--nav-text-muted);">ÉCHELLE</span>
|
||||||
<div class="flex flex-wrap gap-1">
|
<ChipGroup aria-label="Filtrer par échelle">
|
||||||
<span
|
<Chip
|
||||||
v-for="opt in ECHELLES"
|
v-for="opt in ECHELLES"
|
||||||
:key="opt"
|
:key="opt"
|
||||||
class="cursor-pointer px-2 py-0.5 rounded-full text-xs transition-all"
|
:label="opt"
|
||||||
:style="echelle.includes(opt)
|
variant="echelle"
|
||||||
? 'background: var(--nav-primary); color: var(--nav-text-on-primary); font-weight: 600;'
|
:active="echelle.includes(opt)"
|
||||||
: 'background: var(--nav-bg-alt); color: var(--nav-text-muted);'"
|
@toggle="toggleEchelle(opt)"
|
||||||
@click="toggleEchelle(opt)"
|
/>
|
||||||
>{{ opt }}</span>
|
</ChipGroup>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Filtres FONCTION — chips flex-wrap -->
|
<!-- Filtres FONCTION — Chip variant fonction (B3-M1, idem) -->
|
||||||
<div class="mt-2">
|
<div class="mt-2">
|
||||||
<span class="text-xs font-bold uppercase tracking-wide block mb-1" style="color: var(--nav-text-muted);">FONCTION</span>
|
<span class="text-xs font-bold uppercase tracking-wide block mb-1" style="color: var(--nav-text-muted);">FONCTION</span>
|
||||||
<div class="flex flex-wrap gap-1">
|
<ChipGroup aria-label="Filtrer par fonction">
|
||||||
<span
|
<Chip
|
||||||
v-for="fn in FONCTIONS"
|
v-for="fn in FONCTIONS"
|
||||||
:key="fn"
|
:key="fn"
|
||||||
class="cursor-pointer px-2 py-0.5 rounded-full text-xs transition-all"
|
:label="fn"
|
||||||
:style="fonctions.includes(fn)
|
variant="fonction"
|
||||||
? 'background: var(--nav-primary); color: var(--nav-text-on-primary); font-weight: 600;'
|
:active="fonctions.includes(fn)"
|
||||||
: 'background: var(--nav-bg-alt); color: var(--nav-text-muted);'"
|
:count="fonctions.includes(fn) ? (fonctions.indexOf(fn) + 1) : (fonctionCount[fn] ?? 0)"
|
||||||
@click="toggleFonction(fn)"
|
@toggle="toggleFonction(fn)"
|
||||||
>{{ fn }}</span>
|
/>
|
||||||
</div>
|
</ChipGroup>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@@ -330,6 +391,7 @@
|
|||||||
<MissionPopup
|
<MissionPopup
|
||||||
:modelValue="missionOpen"
|
:modelValue="missionOpen"
|
||||||
@update:modelValue="missionOpen = $event"
|
@update:modelValue="missionOpen = $event"
|
||||||
|
echelle="M"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -358,12 +420,14 @@ const territoireMode = ref<string>(
|
|||||||
(route.query.mode as string) === 'outremer' ? 'outremer' : 'metropole'
|
(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 selectedId = ref<number | null>(null)
|
||||||
const chatbotOpen = ref(false)
|
const chatbotOpen = ref(false)
|
||||||
const ficheModalOpen = ref(false)
|
const ficheModalOpen = ref(false)
|
||||||
const ficheModalId = ref<number | null>(null)
|
const ficheModalId = ref<number | null>(null)
|
||||||
const mobileMapView = ref<'metropole' | 'outremer'>('metropole')
|
const mobileMapView = ref<MapView>((route.query.vue as string) === 'fiches' ? 'fiches' : 'metropole')
|
||||||
const missionOpen = ref(false)
|
const missionOpen = ref(false)
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
@@ -411,9 +475,20 @@ function syncUrl() {
|
|||||||
if (fonctions.value.length) q.fonctions = fonctions.value.join(',')
|
if (fonctions.value.length) q.fonctions = fonctions.value.join(',')
|
||||||
if (territoire.value) q.territoire = territoire.value
|
if (territoire.value) q.territoire = territoire.value
|
||||||
if (territoireMode.value === 'outremer') q.mode = 'outremer'
|
if (territoireMode.value === 'outremer') q.mode = 'outremer'
|
||||||
|
if (desktopMapView.value === 'fiches' || mobileMapView.value === 'fiches') q.vue = 'fiches'
|
||||||
router.replace({ query: Object.keys(q).length ? q : undefined })
|
router.replace({ query: Object.keys(q).length ? q : undefined })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Changement de vue (carte/fiches) — passe par syncUrl pour poser/retirer ?vue=fiches
|
||||||
|
function setDesktopView(v: MapView) {
|
||||||
|
desktopMapView.value = v
|
||||||
|
syncUrl()
|
||||||
|
}
|
||||||
|
function setMobileView(v: MapView) {
|
||||||
|
mobileMapView.value = v
|
||||||
|
syncUrl()
|
||||||
|
}
|
||||||
|
|
||||||
// Sauvegarde les filtres courants dans sessionStorage pour le bouton retour des fiches
|
// Sauvegarde les filtres courants dans sessionStorage pour le bouton retour des fiches
|
||||||
function storeFiltersForBack() {
|
function storeFiltersForBack() {
|
||||||
if (typeof window === 'undefined') return
|
if (typeof window === 'undefined') return
|
||||||
@@ -541,6 +616,12 @@ const filtered = computed<Org[]>(() => {
|
|||||||
return result
|
return result
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Panneau « Toutes les fiches » (B3-M3) — mêmes fiches que `filtered`, sans
|
||||||
|
// filtrer par géolocalisation : c'est le gain principal de cette mission,
|
||||||
|
// les fiches sans latitude/longitude (invisibles sur la carte) y apparaissent.
|
||||||
|
const { toMini } = useFicheAdapter()
|
||||||
|
const allFichesMini = computed(() => filtered.value.map((org) => toMini(org)))
|
||||||
|
|
||||||
const DOM_TOM = ['Guadeloupe', 'Martinique', 'Guyane', 'La Réunion', 'Mayotte']
|
const DOM_TOM = ['Guadeloupe', 'Martinique', 'Guyane', 'La Réunion', 'Mayotte']
|
||||||
const DOM_TOM_LIST = DOM_TOM
|
const DOM_TOM_LIST = DOM_TOM
|
||||||
|
|
||||||
|
|||||||
+269
-5
@@ -9,6 +9,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h1 class="outils-header__title">Outils</h1>
|
<h1 class="outils-header__title">Outils</h1>
|
||||||
|
<EchelleTete echelle="S" :contribuer="false" />
|
||||||
<p class="outils-header__intro">
|
<p class="outils-header__intro">
|
||||||
En tant qu'architecte, on jongle avec une multitude d'outils — simulation, dessin,
|
En tant qu'architecte, on jongle avec une multitude d'outils — simulation, dessin,
|
||||||
calcul, recherche, partage. Les mutualiser, se conseiller dessus, savoir lequel
|
calcul, recherche, partage. Les mutualiser, se conseiller dessus, savoir lequel
|
||||||
@@ -137,15 +138,55 @@
|
|||||||
<!-- ══════════════ SECTION 4 — FMHY (cœur de la page) ══════════════ -->
|
<!-- ══════════════ SECTION 4 — FMHY (cœur de la page) ══════════════ -->
|
||||||
<section class="outils-section outils-section--fmhy" aria-labelledby="sec-fmhy">
|
<section class="outils-section outils-section--fmhy" aria-labelledby="sec-fmhy">
|
||||||
<h2 id="sec-fmhy" class="outils-section__title">
|
<h2 id="sec-fmhy" class="outils-section__title">
|
||||||
<span aria-hidden="true">🌳</span> Bibliothèque de ressources libres
|
<span aria-hidden="true">🌳</span> Annuaire du web libre — la cartographie FMHY
|
||||||
</h2>
|
</h2>
|
||||||
<p class="outils-section__desc">
|
<p class="outils-section__desc">
|
||||||
Le vrai trésor de l'onglet Outils. FMHY (Free Media Heck Yeah) est la plus grosse
|
FMHY (FreeMediaHeckYeah) est le plus grand index communautaire de ressources
|
||||||
base communautaire d'outils, services et ressources libres/gratuits du web. J'en ai
|
gratuites du web : des milliers de liens classés et vérifiés par des centaines
|
||||||
curé ~50 entrées pertinentes pour un architecte : IA, lecture, dev, vie privée,
|
de contributeurs bénévoles, mis à jour en continu. C'est une forme de
|
||||||
formation, médias. Clique sur les branches pour explorer.
|
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>
|
</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 class="fmhy-tree-wrap">
|
||||||
<div v-if="fmhyPending" class="fmhy-loading" aria-label="Chargement…">
|
<div v-if="fmhyPending" class="fmhy-loading" aria-label="Chargement…">
|
||||||
<span>Chargement…</span>
|
<span>Chargement…</span>
|
||||||
@@ -188,6 +229,124 @@
|
|||||||
const { data: outils } = await useFetch('/data/outils.json')
|
const { data: outils } = await useFetch('/data/outils.json')
|
||||||
const { data: fmhyData, pending: fmhyPending, error: fmhyError } = await useFetch('/data/fmhy-curated.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({
|
useSeoMeta({
|
||||||
title: 'Outils — AEP',
|
title: 'Outils — AEP',
|
||||||
description: 'Outils partagés entre architectes : simulateurs, open source, ressources libres FMHY, bifurcation post-études.'
|
description: 'Outils partagés entre architectes : simulateurs, open source, ressources libres FMHY, bifurcation post-études.'
|
||||||
@@ -526,6 +685,111 @@ useSeoMeta({
|
|||||||
margin: 0 -0.5rem;
|
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 {
|
.fmhy-tree-wrap {
|
||||||
padding: 0.875rem 0.75rem;
|
padding: 0.875rem 0.75rem;
|
||||||
}
|
}
|
||||||
|
|||||||
+58
-4
@@ -6,8 +6,8 @@
|
|||||||
<div class="proposer-header">
|
<div class="proposer-header">
|
||||||
<h1>Proposer une ressource</h1>
|
<h1>Proposer une ressource</h1>
|
||||||
<p class="proposer-subtitle">
|
<p class="proposer-subtitle">
|
||||||
Tu connais quelque chose d'utile qui n'est pas encore référencé sur AEP ?
|
Colle un lien (ou plusieurs), dis en quelques mots pourquoi c'est utile — on s'occupe du reste.
|
||||||
Choisis la catégorie ci-dessous et soumets-le — on valide sous 7 jours.
|
On valide manuellement avant publication.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -20,6 +20,23 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template v-else>
|
<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">
|
<div class="tabs-bar">
|
||||||
<button
|
<button
|
||||||
v-for="tab in TABS"
|
v-for="tab in TABS"
|
||||||
@@ -38,21 +55,33 @@
|
|||||||
<FormOutils v-else-if="activeTab === 'outils'" @success="onSuccess" />
|
<FormOutils v-else-if="activeTab === 'outils'" @success="onSuccess" />
|
||||||
<FormReferences v-else-if="activeTab === 'references'" @success="onSuccess" />
|
<FormReferences v-else-if="activeTab === 'references'" @success="onSuccess" />
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { TYPES_SUGGERES, type TypeSuggere } from '~/utils/submitLibre'
|
||||||
|
|
||||||
const TABS = [
|
const TABS = [
|
||||||
{ id: 'ecosysteme', label: 'Écosystème / Entraide' },
|
{ id: 'ecosysteme', label: 'Écosystème / Entraide' },
|
||||||
{ id: 'reseau', label: 'Réseau AEP' },
|
{ id: 'reseau', label: 'Réseau AEP' },
|
||||||
{ id: 'jobs', label: 'Jobs' },
|
{ id: 'jobs', label: 'Jobs' },
|
||||||
{ id: 'outils', label: 'Outils' },
|
{ id: 'outils', label: 'Outils' },
|
||||||
{ id: 'references', label: 'Références RAG' },
|
{ id: 'references', label: 'Références (bibliothèque)' },
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
const activeTab = ref<string>('ecosysteme')
|
// ?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)
|
const success = ref(false)
|
||||||
|
|
||||||
function onSuccess() {
|
function onSuccess() {
|
||||||
@@ -62,6 +91,7 @@ function onSuccess() {
|
|||||||
function reset() {
|
function reset() {
|
||||||
success.value = false
|
success.value = false
|
||||||
activeTab.value = 'ecosysteme'
|
activeTab.value = 'ecosysteme'
|
||||||
|
detailleOuvert.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
useHead({ title: 'Proposer une ressource — AEP' })
|
useHead({ title: 'Proposer une ressource — AEP' })
|
||||||
@@ -103,6 +133,30 @@ useHead({ title: 'Proposer une ressource — AEP' })
|
|||||||
margin: 0;
|
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 {
|
.tabs-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
<div class="taff-header">
|
<div class="taff-header">
|
||||||
<div class="taff-header-inner">
|
<div class="taff-header-inner">
|
||||||
<h1 class="taff-title">Trouver du taf en archi</h1>
|
<h1 class="taff-title">Trouver du taf en archi</h1>
|
||||||
|
<EchelleTete echelle="S" :contribuer="false" class="taff-echelle-tete" />
|
||||||
<p class="taff-subtitle">
|
<p class="taff-subtitle">
|
||||||
Annuaire critique des plateformes de mise en relation et d'appels d'offres,
|
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.
|
pour les <strong>architectes indépendants</strong> — 70 % de la profession et sa part la plus précaire économiquement.
|
||||||
@@ -296,7 +297,7 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</Transition>
|
</Transition>
|
||||||
</Teleport>
|
</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 { 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; }
|
.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-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-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; }
|
.taff-cible { font-size: 0.875rem; color: var(--nav-text-muted); line-height: 1.55; margin-bottom: 1rem; font-style: italic; }
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 6.1 KiB |
@@ -7,7 +7,7 @@
|
|||||||
"children": [
|
"children": [
|
||||||
{ "name": "ChatGPT (OpenAI)", "url": "https://chat.openai.com/", "desc": "LLM généraliste, référence." },
|
{ "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": "Claude (Anthropic)", "url": "https://claude.ai/", "desc": "Excellent pour rédaction longue et analyse de documents." },
|
||||||
{ "name": "Mistral Le Chat", "url": "https://chat.mistral.ai/", "desc": "LLM français, souverain, gratuit." },
|
{ "name": "Mistral Le Chat", "url": "https://chat.mistral.ai/", "desc": "LLM français, gratuit." },
|
||||||
{ "name": "Perplexity", "url": "https://www.perplexity.ai/", "desc": "Moteur de recherche IA avec sources citées." },
|
{ "name": "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": "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": "LM Studio", "url": "https://lmstudio.ai/", "desc": "Faire tourner des LLM localement, sans cloud." }
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 527 B |
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,21 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" role="img" aria-label="AEP maillage">
|
||||||
|
<rect width="32" height="32" rx="7" fill="#14142B"/>
|
||||||
|
<g stroke="#E9A23B" stroke-width="1.4" stroke-linecap="round">
|
||||||
|
<line x1="16" y1="6" x2="26.5" y2="13.5"/>
|
||||||
|
<line x1="26.5" y1="13.5" x2="22.5" y2="26"/>
|
||||||
|
<line x1="22.5" y1="26" x2="9.5" y2="26"/>
|
||||||
|
<line x1="9.5" y1="26" x2="5.5" y2="13.5"/>
|
||||||
|
<line x1="5.5" y1="13.5" x2="16" y2="6"/>
|
||||||
|
<line x1="15" y1="16.5" x2="16" y2="6"/>
|
||||||
|
<line x1="15" y1="16.5" x2="22.5" y2="26"/>
|
||||||
|
<line x1="15" y1="16.5" x2="5.5" y2="13.5"/>
|
||||||
|
</g>
|
||||||
|
<g fill="#E9A23B">
|
||||||
|
<circle cx="16" cy="6" r="2.4"/>
|
||||||
|
<circle cx="26.5" cy="13.5" r="2.4"/>
|
||||||
|
<circle cx="22.5" cy="26" r="2.4"/>
|
||||||
|
<circle cx="9.5" cy="26" r="2.4"/>
|
||||||
|
<circle cx="5.5" cy="13.5" r="2.4"/>
|
||||||
|
</g>
|
||||||
|
<circle cx="15" cy="16.5" r="2.9" fill="#F5C560"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 892 B |
@@ -0,0 +1,66 @@
|
|||||||
|
#!/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 } = 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,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
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++
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n${cases.length - failures}/${cases.length} tests passés`)
|
||||||
|
process.exit(failures > 0 ? 1 : 0)
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
// @ts-ignore — JSON import résolu par Rollup
|
// @ts-ignore — JSON import résolu par Rollup
|
||||||
import reseauxData from '../../public/data/reseaux-bifurcation.json'
|
import reseauxData from '../../public/data/reseaux-bifurcation.json'
|
||||||
import { checkRateLimitJson } from '~/server/utils/rateLimitJson'
|
import { checkRateLimitJson } from '~/server/utils/rateLimitJson'
|
||||||
|
import { pickBifrostTier, type BifrostChatResponse } from '~/server/utils/bifrost'
|
||||||
|
|
||||||
interface Structure {
|
interface Structure {
|
||||||
id: string
|
id: string
|
||||||
@@ -61,6 +62,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
const body = await readBody(event)
|
const body = await readBody(event)
|
||||||
const question: string = (body?.question ?? '').trim()
|
const question: string = (body?.question ?? '').trim()
|
||||||
if (!question || question.length < 5) throw createError({ statusCode: 400, message: 'Question trop courte.' })
|
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 structures: Structure[] = ((reseauxData as any).structures ?? [])
|
||||||
const keywords = extractKeywords(question)
|
const keywords = extractKeywords(question)
|
||||||
@@ -82,18 +84,20 @@ export default defineEventHandler(async (event) => {
|
|||||||
|
|
||||||
const systemPrompt = SYSTEM_PROMPT.replace('{{STRUCTURES_JSON}}', JSON.stringify(context, null, 0))
|
const systemPrompt = SYSTEM_PROMPT.replace('{{STRUCTURES_JSON}}', JSON.stringify(context, null, 0))
|
||||||
|
|
||||||
const mistralApiKey = config.mistralApiKey as string
|
const bifrostUrl = config.bifrostUrl as string
|
||||||
if (!mistralApiKey) throw createError({ statusCode: 500, message: 'Clé API Mistral manquante.' })
|
const bifrostVk = config.bifrostVk as string
|
||||||
|
if (!bifrostVk) throw createError({ statusCode: 500, message: 'Clé Bifrost manquante.' })
|
||||||
|
|
||||||
let mistralRaw: string
|
let mistralRaw: string
|
||||||
try {
|
try {
|
||||||
const res = await $fetch<{ choices: { message: { content: string } }[] }>(
|
const res = await $fetch<BifrostChatResponse>(
|
||||||
'https://api.mistral.ai/v1/chat/completions',
|
`${bifrostUrl}/v1/chat/completions`,
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { Authorization: `Bearer ${mistralApiKey}`, 'Content-Type': 'application/json' },
|
headers: { 'x-bf-vk': bifrostVk, 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
model: 'mistral-small-latest',
|
model: tier.model,
|
||||||
|
fallbacks: tier.fallbacks,
|
||||||
temperature: 0.3,
|
temperature: 0.3,
|
||||||
max_tokens: 700,
|
max_tokens: 700,
|
||||||
response_format: { type: 'json_object' },
|
response_format: { type: 'json_object' },
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
// @ts-ignore — JSON import résolu par Vite/Rollup
|
// @ts-ignore — JSON import résolu par Vite/Rollup
|
||||||
import taffData from '../../public/data/plateformes-taff.json'
|
import taffData from '../../public/data/plateformes-taff.json'
|
||||||
import { checkRateLimitJson } from '~/server/utils/rateLimitJson'
|
import { checkRateLimitJson } from '~/server/utils/rateLimitJson'
|
||||||
|
import { pickBifrostTier, type BifrostChatResponse } from '~/server/utils/bifrost'
|
||||||
|
|
||||||
interface PlateformeMinimal {
|
interface PlateformeMinimal {
|
||||||
id: string
|
id: string
|
||||||
@@ -66,6 +67,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
if (!question || question.length < 5) {
|
if (!question || question.length < 5) {
|
||||||
throw createError({ statusCode: 400, statusMessage: 'Question trop courte.' })
|
throw createError({ statusCode: 400, statusMessage: 'Question trop courte.' })
|
||||||
}
|
}
|
||||||
|
const tier = pickBifrostTier(body?.mode)
|
||||||
|
|
||||||
// Données bundlées statiquement à la compilation (import JSON)
|
// Données bundlées statiquement à la compilation (import JSON)
|
||||||
const plateformes: PlateformeMinimal[] = ((taffData as any).plateformes ?? []).map((p: any) => ({
|
const plateformes: PlateformeMinimal[] = ((taffData as any).plateformes ?? []).map((p: any) => ({
|
||||||
@@ -91,20 +93,22 @@ export default defineEventHandler(async (event) => {
|
|||||||
|
|
||||||
const systemPrompt = SYSTEM_PROMPT.replace('{{PLATEFORMES_JSON}}', JSON.stringify(context, null, 0))
|
const systemPrompt = SYSTEM_PROMPT.replace('{{PLATEFORMES_JSON}}', JSON.stringify(context, null, 0))
|
||||||
|
|
||||||
const mistralApiKey = config.mistralApiKey as string
|
const bifrostUrl = config.bifrostUrl as string
|
||||||
if (!mistralApiKey) {
|
const bifrostVk = config.bifrostVk as string
|
||||||
throw createError({ statusCode: 500, statusMessage: 'Clé API Mistral manquante.' })
|
if (!bifrostVk) {
|
||||||
|
throw createError({ statusCode: 500, statusMessage: 'Clé Bifrost manquante.' })
|
||||||
}
|
}
|
||||||
|
|
||||||
let mistralRaw: string
|
let mistralRaw: string
|
||||||
try {
|
try {
|
||||||
const res = await $fetch<{ choices: { message: { content: string } }[] }>(
|
const res = await $fetch<BifrostChatResponse>(
|
||||||
'https://api.mistral.ai/v1/chat/completions',
|
`${bifrostUrl}/v1/chat/completions`,
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { Authorization: `Bearer ${mistralApiKey}`, 'Content-Type': 'application/json' },
|
headers: { 'x-bf-vk': bifrostVk, 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
model: 'mistral-small-latest',
|
model: tier.model,
|
||||||
|
fallbacks: tier.fallbacks,
|
||||||
temperature: 0.3,
|
temperature: 0.3,
|
||||||
max_tokens: 700,
|
max_tokens: 700,
|
||||||
response_format: { type: 'json_object' },
|
response_format: { type: 'json_object' },
|
||||||
|
|||||||
+30
-17
@@ -19,6 +19,7 @@
|
|||||||
|
|
||||||
import { checkRateLimitJson } from '~/server/utils/rateLimitJson'
|
import { checkRateLimitJson } from '~/server/utils/rateLimitJson'
|
||||||
import { checkBudget, calcCoutMistralSmall } from '~/server/utils/circuitBreaker'
|
import { checkBudget, calcCoutMistralSmall } from '~/server/utils/circuitBreaker'
|
||||||
|
import { pickBifrostTier, type BifrostChatResponse } from '~/server/utils/bifrost'
|
||||||
|
|
||||||
// ── Types ──────────────────────────────────────────────────────────────────────
|
// ── Types ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -131,18 +132,19 @@ async function logUsage(params: {
|
|||||||
nocodbUrl: string
|
nocodbUrl: string
|
||||||
nocodbToken: string
|
nocodbToken: string
|
||||||
statsTableId: string
|
statsTableId: string
|
||||||
|
model: string
|
||||||
tokensIn: number
|
tokensIn: number
|
||||||
tokensOut: number
|
tokensOut: number
|
||||||
coutEur: 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`
|
const logUrl = `${nocodbUrl}/api/v2/tables/${statsTableId}/records`
|
||||||
try {
|
try {
|
||||||
await $fetch(logUrl, {
|
await $fetch(logUrl, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'xc-token': nocodbToken, 'Content-Type': 'application/json' },
|
headers: { 'xc-token': nocodbToken, 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
model: 'mistral-small-latest',
|
model,
|
||||||
endpoint: 'chatbot',
|
endpoint: 'chatbot',
|
||||||
tokens_in: tokensIn,
|
tokens_in: tokensIn,
|
||||||
tokens_out: tokensOut,
|
tokens_out: tokensOut,
|
||||||
@@ -180,6 +182,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
const body = await readBody(event)
|
const body = await readBody(event)
|
||||||
const question: string = (body?.question ?? '').trim()
|
const question: string = (body?.question ?? '').trim()
|
||||||
const filters: { fonction?: string; echelle?: string } = body?.filters ?? {}
|
const filters: { fonction?: string; echelle?: string } = body?.filters ?? {}
|
||||||
|
const tier = pickBifrostTier(body?.mode)
|
||||||
|
|
||||||
if (!question || question.length < 3) {
|
if (!question || question.length < 3) {
|
||||||
throw createError({
|
throw createError({
|
||||||
@@ -247,32 +250,32 @@ export default defineEventHandler(async (event) => {
|
|||||||
JSON.stringify(fichesContext, null, 0),
|
JSON.stringify(fichesContext, null, 0),
|
||||||
)
|
)
|
||||||
|
|
||||||
// 6. Appel Mistral Small
|
// 6. Appel Bifrost (gateway LLM — remplace l'appel Mistral direct)
|
||||||
const mistralApiKey = config.mistralApiKey as string
|
const bifrostUrl = config.bifrostUrl as string
|
||||||
|
const bifrostVk = config.bifrostVk as string
|
||||||
|
|
||||||
if (!mistralApiKey) {
|
if (!bifrostVk) {
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 500,
|
statusCode: 500,
|
||||||
statusMessage: 'Clé API Mistral manquante.',
|
statusMessage: 'Clé Bifrost manquante.',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
let mistralRaw: string
|
let mistralRaw: string
|
||||||
let tokensIn = 0
|
let tokensIn = 0
|
||||||
let tokensOut = 0
|
let tokensOut = 0
|
||||||
|
let realModel = tier.model
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const mistralRes = await $fetch<{
|
const bifrostRes = await $fetch<BifrostChatResponse>(`${bifrostUrl}/v1/chat/completions`, {
|
||||||
choices: { message: { content: string } }[]
|
|
||||||
usage?: { prompt_tokens: number; completion_tokens: number }
|
|
||||||
}>('https://api.mistral.ai/v1/chat/completions', {
|
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${mistralApiKey}`,
|
'x-bf-vk': bifrostVk,
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
model: 'mistral-small-latest',
|
model: tier.model,
|
||||||
|
fallbacks: tier.fallbacks,
|
||||||
temperature: 0.3,
|
temperature: 0.3,
|
||||||
max_tokens: 600,
|
max_tokens: 600,
|
||||||
response_format: { type: 'json_object' },
|
response_format: { type: 'json_object' },
|
||||||
@@ -283,11 +286,16 @@ export default defineEventHandler(async (event) => {
|
|||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
mistralRaw = mistralRes.choices?.[0]?.message?.content ?? '{}'
|
mistralRaw = bifrostRes.choices?.[0]?.message?.content ?? '{}'
|
||||||
tokensIn = mistralRes.usage?.prompt_tokens ?? 0
|
tokensIn = bifrostRes.usage?.prompt_tokens ?? 0
|
||||||
tokensOut = mistralRes.usage?.completion_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) {
|
} catch (e: any) {
|
||||||
console.error('[chatbot] Erreur Mistral Small:', e?.message ?? e)
|
console.error('[chatbot] Erreur Bifrost:', e?.message ?? e)
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 502,
|
statusCode: 502,
|
||||||
statusMessage: 'Erreur appel IA — réessaie dans quelques instants.',
|
statusMessage: 'Erreur appel IA — réessaie dans quelques instants.',
|
||||||
@@ -317,11 +325,16 @@ export default defineEventHandler(async (event) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 8. Log usage (non bloquant)
|
// 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({
|
logUsage({
|
||||||
nocodbUrl: config.nocodbUrl as string,
|
nocodbUrl: config.nocodbUrl as string,
|
||||||
nocodbToken: config.nocodbToken as string,
|
nocodbToken: config.nocodbToken as string,
|
||||||
statsTableId,
|
statsTableId,
|
||||||
|
model: realModel,
|
||||||
tokensIn,
|
tokensIn,
|
||||||
tokensOut,
|
tokensOut,
|
||||||
coutEur,
|
coutEur,
|
||||||
|
|||||||
+122
-37
@@ -14,6 +14,8 @@
|
|||||||
|
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { checkRateLimit } from '~/server/utils/rateLimit'
|
import { checkRateLimit } from '~/server/utils/rateLimit'
|
||||||
|
import { notifyNtfy, buildSubmissionNotif } from '~/server/utils/notify'
|
||||||
|
import { LibreSubmitSchema, toOrgaPayload, toReferencePayload, type LibreSubmitInput } from '~/utils/submitLibre'
|
||||||
|
|
||||||
const SUBMISSION_TYPES = ['ecosysteme', 'reseau', 'job', 'outil'] as const
|
const SUBMISSION_TYPES = ['ecosysteme', 'reseau', 'job', 'outil'] as const
|
||||||
|
|
||||||
@@ -103,6 +105,15 @@ export default defineEventHandler(async (event) => {
|
|||||||
|
|
||||||
// 3. Lire et valider le body
|
// 3. Lire et valider le body
|
||||||
const body = await readBody(event)
|
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)
|
const parsed = SubmitSchema.safeParse(body)
|
||||||
|
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
@@ -188,10 +199,17 @@ export default defineEventHandler(async (event) => {
|
|||||||
|
|
||||||
const submissionId = insertedRecord?.Id ?? insertedRecord?.id ?? null
|
const submissionId = insertedRecord?.Id ?? insertedRecord?.id ?? null
|
||||||
|
|
||||||
// 6. Notification email Jules (fire-and-forget — n'impacte pas la réponse)
|
// 6. Notification ntfy à Jules (fire-and-forget — n'impacte pas la réponse)
|
||||||
notifyJules(config, data, submissionId).catch((e) =>
|
notifyNtfy(
|
||||||
console.warn('[submit] Email notification échouée:', e?.message ?? e),
|
process.env.NTFY_TOPIC,
|
||||||
)
|
buildSubmissionNotif({
|
||||||
|
submissionId,
|
||||||
|
nomSuggere: data.nom,
|
||||||
|
typeSuggere: data.submission_type,
|
||||||
|
nbLiens: data.url ? 1 : 0,
|
||||||
|
nocodbAdminUrl: 'http://localhost:8070',
|
||||||
|
}),
|
||||||
|
).catch((e) => console.warn('[submit] Notification ntfy échouée:', e?.message ?? e))
|
||||||
|
|
||||||
return {
|
return {
|
||||||
status: 201,
|
status: 201,
|
||||||
@@ -203,40 +221,107 @@ export default defineEventHandler(async (event) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
async function notifyJules(
|
// ── Formulaire assoupli (B5-M1) ─────────────────────────────────────────────
|
||||||
config: ReturnType<typeof useRuntimeConfig>,
|
|
||||||
data: SubmitInput,
|
|
||||||
submissionId: number | null,
|
|
||||||
): Promise<void> {
|
|
||||||
if (!config.resendApiKey) return
|
|
||||||
|
|
||||||
const nocoAdminUrl = `http://localhost:8070`
|
async function handleLibreSubmit(body: unknown) {
|
||||||
const body = {
|
const parsed = LibreSubmitSchema.safeParse(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>
|
|
||||||
`,
|
|
||||||
}
|
|
||||||
|
|
||||||
await $fetch('https://api.resend.com/emails', {
|
if (!parsed.success) {
|
||||||
method: 'POST',
|
throw createError({
|
||||||
headers: {
|
statusCode: 422,
|
||||||
Authorization: `Bearer ${config.resendApiKey}`,
|
statusMessage: 'Validation échouée',
|
||||||
'Content-Type': 'application/json',
|
data: parsed.error.flatten().fieldErrors,
|
||||||
},
|
|
||||||
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: payload.nom,
|
||||||
|
typeSuggere: payload.submission_type,
|
||||||
|
nbLiens: data.liens.length,
|
||||||
|
nocodbAdminUrl: 'http://localhost:8070',
|
||||||
|
}),
|
||||||
|
).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: submissionId ? `https://nav.trans-former.fr/suivi/${submissionId}` : null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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: payload.titre,
|
||||||
|
typeSuggere: 'references',
|
||||||
|
nbLiens: data.liens.length,
|
||||||
|
nocodbAdminUrl: 'http://localhost:8070',
|
||||||
|
}),
|
||||||
|
).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,47 @@
|
|||||||
|
/**
|
||||||
|
* Bifrost — gateway LLM (remplace les appels directs Mistral)
|
||||||
|
* Endpoint OpenAI-compatible : POST {bifrostUrl}/v1/chat/completions
|
||||||
|
* Auth : header x-bf-vk
|
||||||
|
*
|
||||||
|
* 2 tiers validés (Mission M3, build Bifrost) :
|
||||||
|
* 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)
|
||||||
|
*
|
||||||
|
* ⚠ 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/llama-3.1-8b-instant',
|
||||||
|
fallbacks: [
|
||||||
|
'gemini-oai/models/gemini-2.5-flash-lite',
|
||||||
|
'cohere/command-r-08-2024',
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BIFROST_TIER_APPROFONDI = {
|
||||||
|
model: 'groq/llama-3.3-70b-versatile',
|
||||||
|
fallbacks: [
|
||||||
|
'gemini-oai/models/gemini-2.5-flash',
|
||||||
|
'mistral/mistral-large-latest',
|
||||||
|
'cohere/command-r-plus-08-2024',
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 {
|
||||||
|
choices: { message: { content: string } }[]
|
||||||
|
usage?: { prompt_tokens: number; completion_tokens: number }
|
||||||
|
extra_fields?: { provider?: string; resolved_model_used?: string }
|
||||||
|
}
|
||||||
@@ -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: {
|
extend: {
|
||||||
colors: {
|
colors: {
|
||||||
/* Palette NAV V2 — Sobre institutionnel */
|
/* 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: {
|
nav: {
|
||||||
bg: '#f8f6f1',
|
bg: 'var(--nav-bg)',
|
||||||
'bg-alt':'#eee9df',
|
'bg-alt':'var(--nav-bg-alt)',
|
||||||
surface: '#ffffff',
|
surface: 'var(--nav-surface)',
|
||||||
primary: 'rgba(26, 34, 56, 0.6)',
|
primary: 'var(--nav-primary)',
|
||||||
'primary-solid': '#1a2238',
|
'primary-solid': 'var(--nav-primary-solid)',
|
||||||
accent: '#f5b342',
|
accent: 'var(--nav-accent)',
|
||||||
text: '#1a2238',
|
text: 'var(--nav-text)',
|
||||||
'text-muted': 'rgba(26, 34, 56, 0.55)',
|
'text-muted': 'var(--nav-text-muted)',
|
||||||
'text-on-primary': '#f8f6f1',
|
'text-on-primary': 'var(--nav-text-on-primary)',
|
||||||
},
|
},
|
||||||
/* Héritage V1 — gardé pour composants existants */
|
/* Héritage V1 — gardé pour composants existants */
|
||||||
sage: {
|
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,119 @@
|
|||||||
|
/**
|
||||||
|
* 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',
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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)),
|
||||||
|
// 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'],
|
||||||
|
})
|
||||||
|
|
||||||
|
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'}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function descriptionAvecLiens(texte: string, liens: string[], typeNonPrecise: boolean): string {
|
||||||
|
const parts: string[] = []
|
||||||
|
if (typeNonPrecise) parts.push('Type : non précisé')
|
||||||
|
if (texte.trim()) parts.push(texte.trim())
|
||||||
|
if (liens.length) parts.push(`Liens :\n${liens.join('\n')}`)
|
||||||
|
return parts.join('\n\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mapping vers la table orgas existante (submission_type ecosysteme/reseau/job/outil). */
|
||||||
|
export function toOrgaPayload(data: LibreSubmitInput) {
|
||||||
|
const typeNonPrecise = !data.type_suggere || data.type_suggere === 'references'
|
||||||
|
const submissionType = data.type_suggere && data.type_suggere !== 'references'
|
||||||
|
? TYPE_TO_SUBMISSION_TYPE[data.type_suggere]
|
||||||
|
: 'ecosysteme'
|
||||||
|
|
||||||
|
return {
|
||||||
|
submission_type: submissionType,
|
||||||
|
nom: nomSuggere(data),
|
||||||
|
url: data.liens[0] || null,
|
||||||
|
description_user: descriptionAvecLiens(data.texte, data.liens, typeNonPrecise),
|
||||||
|
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, false),
|
||||||
|
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
|
#!/usr/bin/env node
|
||||||
/**
|
/**
|
||||||
* NAV V2 — Worker enrichissement IA
|
* AEP — Worker enrichissement IA (B5-M2)
|
||||||
* Lancé via systemd timer toutes les 5 minutes
|
* Lancé via systemd timer toutes les 15 minutes (deploy/aep-worker/aep-worker.timer)
|
||||||
* Pipeline : fetch pending → scrape crawl4ai → Mistral Nemo → update NocoDB → log stats
|
* 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 { spawnSync, execSync } from 'child_process';
|
||||||
import { existsSync, writeFileSync, unlinkSync } from 'fs';
|
import { existsSync, writeFileSync, unlinkSync, readFileSync, mkdirSync } from 'fs';
|
||||||
import { tmpdir } from 'os';
|
import { dirname, join } from 'path';
|
||||||
import { join } from 'path';
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
// ─── CONFIG DEPUIS .env ───────────────────────────────────────────────────────
|
// ─── CONFIG DEPUIS .env ───────────────────────────────────────────────────────
|
||||||
const NOCODB_URL = process.env.NOCODB_URL || 'http://localhost:8070';
|
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_BASE = process.env.NOCODB_BASE;
|
||||||
const NOCODB_TABLE_ORGAS = process.env.NOCODB_TABLE_ORGAS;
|
const NOCODB_TABLE_ORGAS = process.env.NOCODB_TABLE_ORGAS;
|
||||||
const NOCODB_TABLE_STATS = process.env.NOCODB_TABLE_STATS;
|
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 BIFROST_URL = process.env.BIFROST_URL || 'http://127.0.0.1:8080';
|
||||||
const RESEND_FROM = process.env.RESEND_FROM || 'contact@trans-former.fr';
|
const BIFROST_VK = process.env.BIFROST_VK;
|
||||||
const EMAIL_JULES = process.env.EMAIL_JULES || 'jules@trans-former.fr';
|
const WORKER_MODEL = process.env.WORKER_MODEL || 'groq/llama-3.1-8b-instant';
|
||||||
|
|
||||||
|
const NTFY_TOPIC = process.env.NTFY_TOPIC;
|
||||||
|
|
||||||
const BUDGET_MAX_EUR = parseFloat(process.env.BUDGET_MAX_EUR || '20');
|
const BUDGET_MAX_EUR = parseFloat(process.env.BUDGET_MAX_EUR || '20');
|
||||||
const WORKER_LIMIT = parseInt(process.env.WORKER_LIMIT || '5');
|
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) ───────────────────────────────────────────
|
// Prix par 1M tokens (USD) — par défaut 0 (Groq gratuit dans le tier RAPIDE Bifrost).
|
||||||
const NEMO_PRICE_IN = 0.02 / 1_000_000; // $0.02 / 1M tokens input
|
// Réglable si le worker bascule un jour sur un modèle payant.
|
||||||
const NEMO_PRICE_OUT = 0.04 / 1_000_000; // $0.04 / 1M tokens output
|
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;
|
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) ─────────
|
// ─── TAXONOMIE VALIDE (apostrophe typographique U+2019 comme NocoDB) ─────────
|
||||||
const VALID_FONCTIONS = [
|
const VALID_FONCTIONS = [
|
||||||
'Juridique', 'Technique', 'Économique', 'Administratif', 'Chantier',
|
'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 ───────────────────────────────────────────────
|
// ─── MAPPING NORMALISATION TAGS ───────────────────────────────────────────────
|
||||||
const TAG_MAP = [
|
const TAG_MAP = [
|
||||||
[['juridique', 'droit', 'litige', 'contrat', 'déontologie', 'décennale', 'médiation', 'pi ', 'propriété intellectuelle', 'ccag', 'marchés publics droit'], 'Juridique'],
|
[['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é'],
|
[['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'],
|
[['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'],
|
[['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'],
|
[['santé mentale', 'burn-out', 'épuisement', 'souffrance', 'bien-être', 'harcèlement', 'stress', 'psychologique', 'équilibre'], 'Santé mentale'],
|
||||||
];
|
];
|
||||||
|
|
||||||
function normalizeTag(raw) {
|
function normalizeTag(raw) {
|
||||||
const t = raw.toLowerCase().trim();
|
const t = String(raw).toLowerCase().trim();
|
||||||
// D'abord chercher correspondance exacte dans les valeurs valides
|
|
||||||
const exact = VALID_FONCTIONS.find(v => v.toLowerCase() === t);
|
const exact = VALID_FONCTIONS.find(v => v.toLowerCase() === t);
|
||||||
if (exact) return exact;
|
if (exact) return exact;
|
||||||
// Sinon chercher par mots-clés
|
|
||||||
for (const [patterns, normalized] of TAG_MAP) {
|
for (const [patterns, normalized] of TAG_MAP) {
|
||||||
if (patterns.some(p => t.includes(p))) return normalized;
|
if (patterns.some(p => t.includes(p))) return normalized;
|
||||||
}
|
}
|
||||||
@@ -117,8 +148,18 @@ async function nocodbPost(tableId, data) {
|
|||||||
return res.json();
|
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 ──────────────────────────────────────────────────
|
// ─── BUDGET CIRCUIT BREAKER ──────────────────────────────────────────────────
|
||||||
async function getBudgetMoisCourant() {
|
async function getBudgetMoisCourant() {
|
||||||
|
if (DRY_RUN) return 0;
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const year = now.getFullYear();
|
const year = now.getFullYear();
|
||||||
const month = now.getMonth(); // 0-indexed
|
const month = now.getMonth(); // 0-indexed
|
||||||
@@ -142,7 +183,12 @@ async function getBudgetMoisCourant() {
|
|||||||
async function logUsage(usage, model, endpoint, orgaId) {
|
async function logUsage(usage, model, endpoint, orgaId) {
|
||||||
const tokensIn = usage?.prompt_tokens || 0;
|
const tokensIn = usage?.prompt_tokens || 0;
|
||||||
const tokensOut = usage?.completion_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, {
|
await nocodbPost(NOCODB_TABLE_STATS, {
|
||||||
model,
|
model,
|
||||||
@@ -166,111 +212,168 @@ async function fetchPendingRows() {
|
|||||||
return data.list || [];
|
return data.list || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── SCRAPING CRAWL4AI (mode HTTP statique, sans Playwright) ─────────────────
|
function loadFixtureRows() {
|
||||||
async function scrapeWithCrawl4ai(url) {
|
if (!existsSync(DRY_RUN_FIXTURE)) {
|
||||||
log(`Scraping: ${url}`);
|
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
|
// ─── LIENS — extraits de url + description_user (format B5-M1 "Liens :\n...") ─
|
||||||
const urlSafe = url.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
|
function extractLinks(row) {
|
||||||
const script = `
|
const text = `${row.url || ''}\n${row.description_user || row.description || ''}`;
|
||||||
import asyncio, sys
|
const matches = text.match(/https?:\/\/[^\s)"'<>]+/g) || [];
|
||||||
from crawl4ai import AsyncWebCrawler, CrawlerRunConfig
|
const cleaned = matches.map(u => u.replace(/[.,;:!?]+$/, ''));
|
||||||
from crawl4ai.async_crawler_strategy import AsyncHTTPCrawlerStrategy
|
return [...new Set(cleaned)];
|
||||||
|
}
|
||||||
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');
|
|
||||||
|
|
||||||
|
// ─── 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 {
|
try {
|
||||||
const result = spawnSync('python3', [scriptPath], {
|
const res = await fetch(url, {
|
||||||
timeout: 180_000,
|
signal: controller.signal,
|
||||||
maxBuffer: 20 * 1024 * 1024,
|
redirect: 'follow',
|
||||||
env: { ...process.env, PYTHONDONTWRITEBYTECODE: '1', PYTHONIOENCODING: 'utf-8' }
|
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 chunks = [];
|
||||||
const stderr = result.stderr?.toString('utf-8') || '';
|
let total = 0;
|
||||||
|
while (true) {
|
||||||
if (result.status === 0 && stdout.length > 50) {
|
const { done, value } = await reader.read();
|
||||||
log(`Scrape OK: ${stdout.length} chars`);
|
if (done) break;
|
||||||
return stdout.trim();
|
chunks.push(value);
|
||||||
} else {
|
total += value.length;
|
||||||
throw new Error(`Scrape failed (code ${result.status}): ${stderr.slice(0, 300)}`);
|
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 {
|
} finally {
|
||||||
try { unlinkSync(scriptPath); } catch {}
|
clearTimeout(timer);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── APPEL MISTRAL NEMO ───────────────────────────────────────────────────────
|
function extractMeta(html, key) {
|
||||||
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.
|
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 :
|
RÈGLES ABSOLUES :
|
||||||
1. Tu ne dois JAMAIS inventer d'informations non présentes dans les sources fournies.
|
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.
|
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.
|
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.
|
4. "description" : neutre, factuelle, en français, max 300 caractères, sans jugement de valeur.
|
||||||
5. Les points_cles sont des phrases courtes (max 12 mots chacune), actionnables pour un architecte.
|
5. "nom" : un nom de fiche court et identifiable, sans le préfixe "[à qualifier]".
|
||||||
6. Pour les tags_fonction, ne propose que des valeurs parmi la liste autorisée.
|
6. "tags" : 1 à 5 valeurs, uniquement parmi la liste autorisée.
|
||||||
|
|
||||||
TAXONOMIE AUTORISÉE :
|
TAGS AUTORISÉS : "Juridique" | "Technique" | "Économique" | "Administratif" | "Chantier" | "Comptabilité" | "Développement" | "Formation" | "Gestion d’agence" | "Santé mentale"
|
||||||
- Échelle (une seule valeur) : "National" | "Régional" | "Départemental" | "Local"
|
TYPE_SUGGERE AUTORISÉ (une seule valeur ou null) : "ecosysteme" | "reseau" | "job" | "outil"
|
||||||
- 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"
|
|
||||||
|
|
||||||
FORMAT DE SORTIE JSON :
|
FORMAT DE SORTIE JSON :
|
||||||
{
|
{
|
||||||
"description_enrichie": "string (max 300 chars, français, neutre, factuel)",
|
"nom": "string | null",
|
||||||
"points_cles": ["string", "string", "string"],
|
"description": "string (max 300 chars, français, neutre, factuel) | null",
|
||||||
"tags_fonction": ["Valeur1", "Valeur2"],
|
"type_suggere": "ecosysteme" | "reseau" | "job" | "outil" | null,
|
||||||
"echelle": "National" | "Régional" | "Départemental" | "Local" | null,
|
"ville": "string | null",
|
||||||
"territoire": "Métropole" | ... | null,
|
"tags": ["string", "..."],
|
||||||
"localisation_ville": "string" | null,
|
|
||||||
"confiance": "haute" | "moyenne" | "faible"
|
"confiance": "haute" | "moyenne" | "faible"
|
||||||
}
|
}
|
||||||
|
|
||||||
Le champ "confiance" reflète ta certitude globale sur l'enrichissement :
|
Le champ "confiance" reflète ta certitude globale :
|
||||||
- "haute" : URL scrapée avec contenu riche, informations claires
|
- "haute" : contenu scrapé riche, informations claires.
|
||||||
- "moyenne" : URL scrapée mais contenu partiel, ou description_user seule suffisante
|
- "moyenne" : contenu partiel, ou texte du contributeur seul mais clair.
|
||||||
- "faible" : URL non disponible et description_user vague, inférences importantes`;
|
- "faible" : aucun contenu scrapé et texte du contributeur vague, inférences importantes.`;
|
||||||
|
|
||||||
function buildUserPrompt(row, scrapeContent) {
|
function buildUserPrompt(row, scrapeData, autresLiens) {
|
||||||
return `ORGANISATION À ENRICHIR :
|
return `RESSOURCE À QUALIFIER :
|
||||||
|
|
||||||
Nom : ${row.nom}
|
Nom actuel (placeholder à remplacer) : ${row.nom}
|
||||||
URL : ${row.url || 'non fournie'}
|
Lien principal : ${row.url || 'non fourni'}
|
||||||
Description soumise par l'utilisateur : ${row.description_user || row.description || 'non fournie'}
|
${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) :
|
CONTENU EXTRAIT DU SITE (scraping léger) :
|
||||||
${scrapeContent || 'Site non accessible ou URL non fournie.'}
|
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) {
|
async function callBifrostWithRetry(row, scrapeData, autresLiens, maxRetries = 2) {
|
||||||
const userPrompt = buildUserPrompt(row, scrapeContent);
|
const userPrompt = buildUserPrompt(row, scrapeData, autresLiens);
|
||||||
|
|
||||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||||
if (attempt > 0) {
|
if (attempt > 0) {
|
||||||
@@ -279,14 +382,14 @@ async function callMistralWithRetry(row, scrapeContent, maxRetries = 2) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch('https://api.mistral.ai/v1/chat/completions', {
|
const res = await fetch(`${BIFROST_URL}/v1/chat/completions`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': `Bearer ${MISTRAL_API_KEY}`,
|
'x-bf-vk': BIFROST_VK,
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
model: 'open-mistral-nemo',
|
model: WORKER_MODEL,
|
||||||
temperature: 0.2,
|
temperature: 0.2,
|
||||||
max_tokens: 800,
|
max_tokens: 800,
|
||||||
response_format: { type: 'json_object' },
|
response_format: { type: 'json_object' },
|
||||||
@@ -300,101 +403,88 @@ async function callMistralWithRetry(row, scrapeContent, maxRetries = 2) {
|
|||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const err = await res.text();
|
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 data = await res.json();
|
||||||
const content = data.choices?.[0]?.message?.content;
|
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);
|
const parsed = JSON.parse(content);
|
||||||
// Attacher usage pour logging
|
|
||||||
parsed._usage = data.usage;
|
parsed._usage = data.usage;
|
||||||
parsed._raw = content;
|
parsed._raw = content;
|
||||||
return parsed;
|
return parsed;
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
log(`Erreur Mistral (tentative ${attempt + 1}): ${e.message}`);
|
log(`Erreur Bifrost (tentative ${attempt + 1}): ${e.message}`);
|
||||||
if (attempt === maxRetries) return null;
|
if (attempt === maxRetries) return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── EMAIL JULES VIA RESEND ───────────────────────────────────────────────────
|
const MOCK_BIFROST_RESULT = {
|
||||||
async function sendEmailJules(subject, body) {
|
nom: '[mock dry-run] Nom suggéré',
|
||||||
if (!RESEND_API_KEY) {
|
description: '[mock dry-run] Description factice générée sans appel réseau.',
|
||||||
log('RESEND_API_KEY absent, email skippé');
|
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;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const res = await fetch('https://api.resend.com/emails', {
|
const res = await fetch(`https://ntfy.sh/${NTFY_TOPIC}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: { Title: title, Priority: '3', Tags: 'inbox_tray' },
|
||||||
'Authorization': `Bearer ${RESEND_API_KEY}`,
|
body: message,
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
from: RESEND_FROM,
|
|
||||||
to: [EMAIL_JULES],
|
|
||||||
subject,
|
|
||||||
text: body
|
|
||||||
})
|
|
||||||
});
|
});
|
||||||
if (!res.ok) log('Email error:', await res.text());
|
if (!res.ok) log('ntfy erreur:', await res.text());
|
||||||
else log('Email envoyé à Jules:', subject);
|
else log('ntfy envoyé:', title);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
log('Email exception:', e.message);
|
log('ntfy 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 !`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── MAIN ─────────────────────────────────────────────────────────────────────
|
// ─── MAIN ─────────────────────────────────────────────────────────────────────
|
||||||
async function run() {
|
async function run() {
|
||||||
if (!acquireLock()) {
|
if (!DRY_RUN && !acquireLock()) {
|
||||||
log('Worker déjà en cours, skip');
|
log('Worker déjà en cours, skip');
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
log('=== Worker NAV enrichissement démarré ===');
|
log(`=== Worker AEP enrichissement démarré${DRY_RUN ? ' (--dry-run)' : ''} ===`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Vérification clés obligatoires
|
if (!DRY_RUN) {
|
||||||
if (!NOCODB_TOKEN || !NOCODB_BASE || !NOCODB_TABLE_ORGAS || !MISTRAL_API_KEY) {
|
if (!NOCODB_TOKEN || !NOCODB_BASE || !NOCODB_TABLE_ORGAS || !BIFROST_VK) {
|
||||||
throw new Error('Variables .env manquantes (NOCODB_TOKEN, NOCODB_BASE, NOCODB_TABLE_ORGAS, MISTRAL_API_KEY)');
|
throw new Error('Variables .env manquantes (NOCODB_TOKEN, NOCODB_BASE, NOCODB_TABLE_ORGAS, BIFROST_VK)');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check budget global
|
|
||||||
const budgetMois = await getBudgetMoisCourant();
|
const budgetMois = await getBudgetMoisCourant();
|
||||||
log(`Budget mois courant: €${budgetMois.toFixed(4)} / €${BUDGET_MAX_EUR}`);
|
log(`Budget mois courant: €${budgetMois.toFixed(4)} / €${BUDGET_MAX_EUR}`);
|
||||||
|
|
||||||
if (budgetMois >= BUDGET_MAX_EUR) {
|
if (budgetMois >= BUDGET_MAX_EUR) {
|
||||||
log('Budget épuisé pour ce mois. Worker en pause.');
|
log('Budget épuisé pour ce mois. Worker en pause.');
|
||||||
await sendEmailJules(
|
await notifyNtfy('[AEP] Budget IA épuisé', `Le budget de ${BUDGET_MAX_EUR}€ a été atteint. Worker en pause jusqu'au 1er du mois prochain.`);
|
||||||
'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)}`
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch fiches pending
|
const rows = DRY_RUN ? loadFixtureRows() : await fetchPendingRows();
|
||||||
const rows = await fetchPendingRows();
|
log(`${rows.length} fiche(s) à traiter${DRY_RUN ? ' (fixture)' : ''}`);
|
||||||
log(`${rows.length} fiche(s) à traiter`);
|
|
||||||
|
|
||||||
if (rows.length === 0) {
|
if (rows.length === 0) {
|
||||||
log('Rien à traiter.');
|
log('Rien à traiter.');
|
||||||
@@ -407,71 +497,86 @@ async function run() {
|
|||||||
const rowStart = Date.now();
|
const rowStart = Date.now();
|
||||||
log(`--- Traitement fiche ${row.Id}: ${row.nom} ---`);
|
log(`--- Traitement fiche ${row.Id}: ${row.nom} ---`);
|
||||||
|
|
||||||
// Re-check budget avant chaque fiche
|
|
||||||
const budgetCheck = await getBudgetMoisCourant();
|
const budgetCheck = await getBudgetMoisCourant();
|
||||||
if (budgetCheck >= BUDGET_MAX_EUR) {
|
if (budgetCheck >= BUDGET_MAX_EUR) {
|
||||||
log('Budget atteint mid-pipeline, arrêt.');
|
log('Budget atteint mid-pipeline, arrêt.');
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scraping
|
const liens = extractLinks(row);
|
||||||
let scrapeContent = null;
|
const primaryUrl = row.url && row.url.trim() ? row.url.trim() : null;
|
||||||
const hasUrl = row.url && row.url.trim().length > 0;
|
const autresLiens = liens.filter(l => l !== primaryUrl);
|
||||||
const shouldScrape = hasUrl && (row.scrape_status === 'pending' || !row.scrape_status);
|
|
||||||
|
// 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) {
|
if (shouldScrape) {
|
||||||
try {
|
try {
|
||||||
scrapeContent = await scrapeWithCrawl4ai(row.url);
|
scrapeData = useNetwork ? await scrapeLight(primaryUrl) : MOCK_SCRAPE_RESULT;
|
||||||
await nocodbPatch(NOCODB_TABLE_ORGAS, row.Id, {
|
await patchRow(row.Id, {
|
||||||
scrape_status: 'scraped',
|
scrape_status: 'scraped',
|
||||||
scrape_content: scrapeContent
|
scrape_content: JSON.stringify(scrapeData),
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
log(`Scrape échoué: ${e.message}`);
|
log(`Scrape échoué: ${e.message}`);
|
||||||
await nocodbPatch(NOCODB_TABLE_ORGAS, row.Id, { scrape_status: 'failed' });
|
await patchRow(row.Id, { scrape_status: 'failed' });
|
||||||
// Continue avec l'IA sans contenu scrape
|
|
||||||
}
|
}
|
||||||
} else if (!hasUrl) {
|
} else if (!primaryUrl) {
|
||||||
await nocodbPatch(NOCODB_TABLE_ORGAS, row.Id, { scrape_status: 'no_link' });
|
await patchRow(row.Id, { scrape_status: 'no_link' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Appel Mistral Nemo
|
// Appel LLM (Bifrost)
|
||||||
const enriched = await callMistralWithRetry(row, scrapeContent);
|
const enriched = useNetwork
|
||||||
|
? await callBifrostWithRetry(row, scrapeData, autresLiens)
|
||||||
|
: MOCK_BIFROST_RESULT;
|
||||||
|
|
||||||
if (!enriched) {
|
if (!enriched) {
|
||||||
log(`Échec Mistral sur fiche ${row.Id}, flag ai_error`);
|
log(`Échec Bifrost sur fiche ${row.Id}, flag ai_error`);
|
||||||
await nocodbPatch(NOCODB_TABLE_ORGAS, row.Id, {
|
await patchRow(row.Id, { moderation_status: 'ai_error', ai_processed: true });
|
||||||
moderation_status: 'ai_error',
|
|
||||||
ai_processed: true
|
|
||||||
});
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Normalisation tags
|
// Normalisation tags
|
||||||
const rawTags = enriched.tags_fonction || [];
|
const rawTags = enriched.tags || enriched.tags_fonction || [];
|
||||||
const normalizedTags = [...new Set(rawTags.map(normalizeTag).filter(Boolean))];
|
const normalizedTags = [...new Set(rawTags.map(normalizeTag).filter(Boolean))];
|
||||||
|
|
||||||
// Update NocoDB
|
|
||||||
const updateData = {
|
const updateData = {
|
||||||
description_enrichie: enriched.description_enrichie || null,
|
description_enrichie: enriched.description || null,
|
||||||
points_cles: enriched.points_cles ? JSON.stringify(enriched.points_cles) : null,
|
|
||||||
tags_fonction: normalizedTags.join(','),
|
tags_fonction: normalizedTags.join(','),
|
||||||
moderation_status: 'ai_processed',
|
moderation_status: 'ai_processed',
|
||||||
ai_processed: true,
|
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
|
// Nom : ne remplacer que le placeholder posé par le formulaire assoupli (B5-M1).
|
||||||
if (enriched.echelle && !row.echelle) updateData.echelle = enriched.echelle;
|
if (enriched.nom && typeof row.nom === 'string' && row.nom.startsWith('[à qualifier]')) {
|
||||||
if (enriched.territoire && !row.territoire) updateData.territoire = enriched.territoire;
|
updateData.nom = enriched.nom;
|
||||||
if (enriched.localisation_ville && !row.localisation_ville) {
|
}
|
||||||
updateData.localisation_ville = enriched.localisation_ville;
|
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 notifyNtfy(
|
||||||
await logUsage(enriched._usage, 'open-mistral-nemo', 'enrichissement', row.Id);
|
'[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);
|
const elapsed = ((Date.now() - rowStart) / 1000).toFixed(1);
|
||||||
log(`Fiche ${row.Id} traitée en ${elapsed}s — confiance: ${enriched.confiance || 'nc'}`);
|
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 ===`);
|
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) {
|
} catch (e) {
|
||||||
log('ERREUR WORKER:', e.message);
|
log('ERREUR WORKER:', e.message);
|
||||||
console.error(e.stack);
|
console.error(e.stack);
|
||||||
process.exit(1);
|
process.exitCode = 1;
|
||||||
} finally {
|
} 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