Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1838f4b02d | ||
|
|
5b504b3e04 | ||
|
|
b2b8c13fee | ||
|
|
4b1340f160 | ||
|
|
0903b8989b | ||
|
|
b5aca80bbc | ||
|
|
946ed53aba | ||
|
|
d06c09a476 | ||
|
|
4291fe7529 |
@@ -1,188 +0,0 @@
|
|||||||
---
|
|
||||||
type: pilote
|
|
||||||
projet: AEP — nav-carte (aep.trans-former.fr)
|
|
||||||
casquette: Stack technique
|
|
||||||
pilote: ATIS Dev
|
|
||||||
created: 2026-06-23
|
|
||||||
updated: 2026-06-28
|
|
||||||
---
|
|
||||||
|
|
||||||
# PILOTE — AEP / nav-carte
|
|
||||||
|
|
||||||
> Doc de pilotage vivant du site **aep.trans-former.fr**. Source de vérité opérationnelle : état déployé, chantiers, décisions, dette. Le journal de dev détaillé reste dans `JOURNAL-V2.md`. Ce doc est l'index de pilotage, pas le journal.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Ce que c'est
|
|
||||||
|
|
||||||
- **Site** : `https://aep.trans-former.fr` — cartographie politique AEP (Architecture En Plus / écosystème militant).
|
|
||||||
- **Stack** : Nuxt 3 (Nitro SSR) + Vue + Leaflet + D3. Backend RAG = LightRAG (Docker).
|
|
||||||
- **Repo** : `1 PROJETS/TECH .../nav-carte/` — remote Gitea `git.trans-former.fr/jules/nav-carte.git`.
|
|
||||||
- **Pilote** : ATIS Dev (ne code pas, dispatche des agents Sonnet).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Architecture déployée (VÉRIFIÉE 2026-06-23) ⚠ lire avant toute session
|
|
||||||
|
|
||||||
Source fréquente de confusion — état réel constaté sur le VPS :
|
|
||||||
|
|
||||||
```
|
|
||||||
aep.trans-former.fr
|
|
||||||
│ Caddy : reverse_proxy localhost:3333
|
|
||||||
▼
|
|
||||||
aep.service ← LE service réel (systemd, active/running, Nitro Node)
|
|
||||||
├── App dir : /opt/aep
|
|
||||||
├── ExecStart : node /opt/aep/.output/server/index.mjs
|
|
||||||
├── EnvironmentFile: /opt/aep/.env (contient RAG_PE_URL, MISTRAL_API_KEY, etc.)
|
|
||||||
└── Port : 3333
|
|
||||||
|
|
||||||
LightRAG PE : localhost:9621 (Docker, health 200 ✓) ← backend du chatbot RAG
|
|
||||||
|
|
||||||
PIÈGES / fausses pistes :
|
|
||||||
✗ nav-carte.service → inactive/dead (ANCIEN nom, abandonné — ne pas chercher de ce côté)
|
|
||||||
✗ nav-worker.service → FAILED (worker enrichissement IA, mort — à statuer)
|
|
||||||
✗ /opt/nav-carte → n'existe pas (le code n'est PAS sur le VPS en git)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Mécanisme de déploiement = MANUEL, scp/tar (PAS de git pull sur le VPS) :**
|
|
||||||
|
|
||||||
```
|
|
||||||
1. npm run build (local → génère .output/)
|
|
||||||
2. scp/rsync .output/ → vps-hetzner:/opt/aep/.output/
|
|
||||||
3. ssh vps-hetzner "systemctl restart aep.service"
|
|
||||||
4. vérifier : curl -I https://aep.trans-former.fr/<route>
|
|
||||||
```
|
|
||||||
|
|
||||||
**Disque VPS : 91 % (3,5 G libres) — à surveiller, pas bloquant.**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. État git du repo (2026-06-23)
|
|
||||||
|
|
||||||
- `main` == `gitea/main` == `cd8fe9e2` (0 ahead / 0 behind, parfaitement sync).
|
|
||||||
- **Le commit déployé sur le VPS est PÉRIMÉ** (antérieur à `cd8fe9e2`) → `/outils`, `/media`, routes chatbot = 404 live alors que le code existe sur `main`.
|
|
||||||
- **Piège repos imbriqués** : `nav-carte/` est un sous-repo dans le vault. Le vault parent n'est PLUS un repo git (chirurgie 2026-05-07). Travailler git uniquement au niveau `nav-carte/`.
|
|
||||||
|
|
||||||
### Travail local non committé au 2026-06-23
|
|
||||||
|
|
||||||
| Fichier | État | Destination |
|
|
||||||
|---------|------|-------------|
|
|
||||||
| `components/ChatbotPensees.vue` | untracked | **P0/P1 — à committer + déployer** (chatbot RAG) |
|
|
||||||
| `server/api/chatbot-pensees.post.ts` | untracked | **P0/P1 — à committer + déployer** (route chatbot, appelle LightRAG :9621) |
|
|
||||||
| `nuxt.config.ts` | modifié (+1) | **P0/P1** — ajoute `runtimeConfig.ragPeUrl` (requis par la route chatbot) |
|
|
||||||
| `components/MediaTabVisuel.vue` | modifié (CSS) | **P0/P1** — layout toolbar/carte/chatbot |
|
|
||||||
| `pages/a-propos.vue` | modifié (+64) | **P0/P1** — contenu À propos |
|
|
||||||
| `pages/contribuer-job.vue` | untracked (stub) | **P2** — laisser untracked (feature Proposer) |
|
|
||||||
| `pages/contribuer-reseau.vue` | untracked (stub) | **P2** — laisser untracked |
|
|
||||||
| `.firecrawl/` | untracked | junk — gitignore |
|
|
||||||
| `pages/index-v1-backup.vue.tmp.*` | untracked | junk — supprimer |
|
|
||||||
|
|
||||||
### Branches dispersées (dette — voir §6)
|
|
||||||
|
|
||||||
```
|
|
||||||
main ← prod
|
|
||||||
feat/outils-v1 / -build / -media ← travail Outils (en partie déjà sur main)
|
|
||||||
feat/aep-v1.1-nav-carte ← contient a6fff9a9 "PA3 bouton Proposer contextuel"
|
|
||||||
feat/aep-pratiques-regeneratives ← contient d10586c4 "page /proposer-pratique" + form
|
|
||||||
feat/aep-rag-pensees-ecologiques ← travail RAG/chatbot
|
|
||||||
feat/aep-taff-v1 / feat/aep-v2-restore / feat/codev-mvp / wip-stash-v2-onglet2-20260506
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Chantiers (état au 2026-06-23)
|
|
||||||
|
|
||||||
| Prio | Chantier | Diagnostic | Statut | Nature |
|
|
||||||
|------|----------|-----------|--------|--------|
|
|
||||||
| **P0** | Onglet Outils + page /media + redirect /rag manquants live | Build VPS périmé (code committé, pas déployé) | ✅ **Fait 2026-06-23** (commit `f3ab28bd` déployé, routes 200, /rag→301) | Mécanique : build + deploy |
|
|
||||||
| **P1** | Chatbot RAG cassé | Route + composant non committés ; LightRAG PE :9621 = UP | ✅ **Fait 2026-06-23** (route déployée, testé public OK, réponses sourcées) | Mixte : commit + deploy + test |
|
|
||||||
| **P2** | Interface Proposer 5 onglets (Écosystème/Réseau/Jobs/Outils/Références RAG) | Feature dispersée sur branches feature jamais mergées ; `main` n'a qu'1 form (`contribuer.vue`) + 2 stubs untracked | ✅ **Fait 2026-06-23** (commit `c860dabd`, déployé, testé 5/5 types 201) | 4 types → NocoDB `orgas` + `submission_type` ; Réfs → `ressources_references` ; `/contribuer` → 301 → `/proposer` |
|
|
||||||
| **P3** | Google Search Console / indexation | Non diagnostiqué | Reporté (Jules) | Voir `pilotage-indexation-seo.md` (dossier TECH) |
|
|
||||||
| **dette** | Organisation docs/branches/services | Multi-build folders, 10+ Caddyfile.bak, services morts | À cadrer | Voir §6 |
|
|
||||||
|
|
||||||
### Mapping bugs Jules → vraie cause
|
|
||||||
|
|
||||||
```
|
|
||||||
"mauvais commit, RAG/Outils pas up" → FAUX : commit OK. Build VPS périmé. → P0
|
|
||||||
"bouton Proposer 1 carte / 4" → feature sur branches non mergées. → P2
|
|
||||||
"chatbot slug rag/media KO" → redirect /rag→/media en code (pas déployé) → P0/P1
|
|
||||||
+ composant chatbot non committé
|
|
||||||
+ backend LightRAG PE :9621 = UP ✓
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Décisions techniques runtime
|
|
||||||
|
|
||||||
| # | Décision | Pourquoi |
|
|
||||||
|---|----------|----------|
|
|
||||||
| τ.1 | P0+P1 (deploy + chatbot) cette session ; P2 (Proposer) session dédiée | P2 = archéologie multi-branches, dilue le fix de deploy |
|
|
||||||
| τ.2 | Stubs `contribuer-*` laissés untracked au deploy P0 | Ne pas exposer de formulaires inachevés en prod |
|
|
||||||
| τ.3 | Commit P0 = chatbot route + composant + nuxt.config + MediaTabVisuel + a-propos uniquement | Périmètre minimal du « remettre up » ; pas de WIP Proposer |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Dette d'organisation (constatée 2026-06-23)
|
|
||||||
|
|
||||||
Le système tech est éclaté. Symptômes :
|
|
||||||
- Dossier projet `TECH - infra VPS, website pro, RAG/` mélange **6+ projets** (nav-carte/AEP, Website Pro/trans-former.fr, RAG Militant, Simulateur autonomie V2, codev, vps-kit…).
|
|
||||||
- Multiples dossiers `*-build` (cascades méga-dispatch passées : `aep-communaute-build`, `codev-build`, `astro-site-cerveau`).
|
|
||||||
- 10+ `Caddyfile.bak.*` sur le VPS, services systemd morts (`nav-carte.service`, `nav-worker.service`).
|
|
||||||
- Branches feature jamais mergées ni nettoyées.
|
|
||||||
- Plusieurs `.md` de pilotage épars à la racine TECH (`next-steps`, `stack-opencode-v2`, `pilotage-indexation-seo`…).
|
|
||||||
|
|
||||||
→ **Proposition de rangement : voir le plan dans le message de session du 2026-06-23 / à formaliser en note dédiée.**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Journal & refs
|
|
||||||
|
|
||||||
- Journal dev détaillé : `JOURNAL-V2.md` (même dossier).
|
|
||||||
- Mémoire pilote : `2 CASQUETTES/Stack technique/ATIS-Dev.md`.
|
|
||||||
- Prompt P2 : `0 INBOX/PROMPTS/aep-P2-proposer-4-onglets.md`.
|
|
||||||
- SEO/indexation : `1 PROJETS/TECH .../pilotage-indexation-seo.md`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Log de pilotage
|
|
||||||
|
|
||||||
### 2026-06-23 — Session diagnostic (ATIS Dev / Opus)
|
|
||||||
**Accompli :**
|
|
||||||
- Diagnostic complet : renversé l'hypothèse « mauvais commit » → build VPS périmé + travail local non committé.
|
|
||||||
- Cartographié l'archi de service réelle (`aep.service`, pas `nav-carte.service`).
|
|
||||||
- Confirmé LightRAG PE :9621 UP, deploy = scp manuel vers `/opt/aep/.output`.
|
|
||||||
- Créé ce doc pilote + prompt P2.
|
|
||||||
|
|
||||||
**Accompli (suite) :**
|
|
||||||
- ✅ P0+P1 déployés : commit `f3ab28bd` build+push (agent Sonnet), deploy prod (tar+scp → `/opt/aep/.output`, restart `aep.service`). Vérifié : `/outils`, `/media`, `/contribuer`, `/trouver-du-taf` = 200 ; `/rag` → 301 ; chatbot RAG public OK (réponses sourcées via LightRAG :9621).
|
|
||||||
- Prompts P2 + hygiène TECH écrits avec préflight git anti-superposition + clause supervision pilote.
|
|
||||||
|
|
||||||
**Prochaines étapes :**
|
|
||||||
- [x] P2 : Proposer 5 onglets — fait 2026-06-23 (commit c860dabd, deploye, teste 5/5 types 201).
|
|
||||||
- [x] Hygiene TECH : execute 2026-06-23 (agent Sonnet). Voir recap dans `_infra/TECH.md`.
|
|
||||||
- [ ] **Batch 3 (prochain chantier)** : bouton selecteur 3 cartes + popup 5 axes Jobs + GraphView Carte 1. Prompt : `REPRISE-aep-carto-fix-batch3.md`. Session dediee.
|
|
||||||
- [ ] AEP nav-carte — verifier chatbots EN PROD : `/` = chatbot Entraide / `/agences` = chatbot Reseaux AEP distincts.
|
|
||||||
- [ ] P3 : Google Search Console (reporte).
|
|
||||||
|
|
||||||
### 2026-06-23 — Session hygiène TECH (AXE 1+2+3) + fix chatbot (Agent Sonnet)
|
|
||||||
**Accompli :**
|
|
||||||
- **AXE 1 VPS** : docker prune (156M), décommission listmonk (conteneur + image + 2 volumes + 2 routes Caddy), décommission heyform (volume 528M + compose), décommission uptime (volume 88M + route status), `/opt/migration` supprimé (252M, aucune réf active), nav-worker + nav-carte services nettoyés, Caddyfile.bak réduit de 17→4 fichiers. Disque 91%→87% (+~1.3 GB).
|
|
||||||
- **AXE 2 Docs** : `_archive-build/` créé (3 dossiers `*-build` déplacés), `_infra/TECH.md` créé (inventaire VPS complet), `INDEX.md` créé, 3 squelettes `PILOTE.md` (Website Pro, RAG Militant, Simulateur autonomie V2). 4 `.md` épars routés.
|
|
||||||
- **AXE 3 Git** : 4 branches à 0 commit supprimées (`feat/outils-v1`, `-build`, `-media`, `feat/codev-mvp`). `feat/outils-v1-build` worktree retiré.
|
|
||||||
- **Fix chatbot régresse** : `auteurs-pensees.json` (171 auteurs) restauré en prod (scp), versionné sur `main` (commit `04b02351`), push gitea. Worktree `nav-carte-media` retiré, les 4 fichiers untrackés sauvés sur `feat/outils-v1-media` push gitea (branche préservée).
|
|
||||||
|
|
||||||
### 2026-06-23 — Session P2 : Proposer 5 onglets (ATIS Dev / Sonnet)
|
|
||||||
**Accompli :**
|
|
||||||
- Interface Proposer avec 5 tabs (Écosystème/Réseau/Jobs/Outils/Références RAG)
|
|
||||||
- 4 types (ecosysteme/reseau/job/outil) → NocoDB `orgas` table avec `submission_type`
|
|
||||||
- Références → table dédiée NocoDB `ressources_references`
|
|
||||||
- `/contribuer` → 301 redirect vers `/proposer`
|
|
||||||
- 6 corrections pilote appliquées
|
|
||||||
- Build + deploy VPS (scp .output/ + systemctl restart aep)
|
|
||||||
- Tests : 1 par type (4×201), 1 référence (201), test records supprimés (404)
|
|
||||||
- Routes vérifiées : /proposer (200), /contribuer (301), /api/submit (201), /api/submit/reference (201)
|
|
||||||
- ✅ Tout est bon, GO pilote obtenu, P2 terminé
|
|
||||||
|
|
||||||
**Prochaine étape :**
|
|
||||||
- [ ] Push `main` → `gitea/main` (P2 + fixes chatbot non encore poussés)
|
|
||||||
- [ ] P3 : Google Search Console (reporté)
|
|
||||||
+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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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,135 @@
|
|||||||
|
<!--
|
||||||
|
FichesPanel — B3-M3
|
||||||
|
Panneau « toutes les fiches » : reçoit une liste déjà filtrée (recherche +
|
||||||
|
filtres en cours côté page), l'affiche en grille de FicheMiniCard, et
|
||||||
|
expose ses propres chips de filtrage branchées sur le MÊME état que la
|
||||||
|
page appelante (pas un second état — voir vérification du brief).
|
||||||
|
Ne crée pas de second champ de recherche : `query` sert uniquement à
|
||||||
|
nuancer le message d'état vide.
|
||||||
|
-->
|
||||||
|
<template>
|
||||||
|
<div class="fiches-panel">
|
||||||
|
<div class="fiches-panel__head">
|
||||||
|
<p class="fiches-panel__intention">{{ intention }}</p>
|
||||||
|
|
||||||
|
<ChipGroup v-if="hashtags.length" aria-label="Filtrer les fiches" class="fiches-panel__chips">
|
||||||
|
<Chip
|
||||||
|
v-for="tag in hashtags"
|
||||||
|
:key="tag"
|
||||||
|
:label="tag"
|
||||||
|
variant="fonction"
|
||||||
|
:active="selected.includes(tag)"
|
||||||
|
@toggle="toggle(tag)"
|
||||||
|
/>
|
||||||
|
</ChipGroup>
|
||||||
|
|
||||||
|
<div class="fiches-panel__count">
|
||||||
|
{{ fiches.length }} fiche{{ fiches.length > 1 ? 's' : '' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="fiches-panel__body">
|
||||||
|
<p v-if="fiches.length === 0" class="fiches-panel__empty">
|
||||||
|
Aucun résultat<template v-if="query"> pour « {{ query }} »</template>.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div v-else class="fiches-panel__grid">
|
||||||
|
<FicheMiniCard
|
||||||
|
v-for="fiche in fiches"
|
||||||
|
:key="`${fiche.kind}-${fiche.id}`"
|
||||||
|
:fiche="fiche"
|
||||||
|
@open="emit('open', fiche)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { MiniFiche } from '~/composables/useFicheAdapter'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
fiches: MiniFiche[]
|
||||||
|
hashtags: string[]
|
||||||
|
selected: string[]
|
||||||
|
query: string
|
||||||
|
intention: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:selected': [value: string[]]
|
||||||
|
open: [fiche: MiniFiche]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
function toggle(tag: string) {
|
||||||
|
if (props.selected.includes(tag)) {
|
||||||
|
emit('update:selected', props.selected.filter((t) => t !== tag))
|
||||||
|
} else {
|
||||||
|
emit('update:selected', [...props.selected, tag])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.fiches-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fiches-panel__head {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: var(--space-sm) var(--space-md);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fiches-panel__intention {
|
||||||
|
margin: 0 0 var(--space-xs);
|
||||||
|
font-size: var(--fs-sm);
|
||||||
|
line-height: var(--lh-base);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fiches-panel__count {
|
||||||
|
margin-top: var(--space-xs);
|
||||||
|
font-size: var(--fs-xs);
|
||||||
|
font-weight: var(--fw-semibold);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
color: var(--text-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fiches-panel__body {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: var(--space-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fiches-panel__empty {
|
||||||
|
margin: var(--space-xl) 0;
|
||||||
|
text-align: center;
|
||||||
|
font-size: var(--fs-sm);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fiches-panel__grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.fiches-panel__grid {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1280px) {
|
||||||
|
.fiches-panel__grid {
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
}
|
||||||
+7
-1
@@ -1,6 +1,8 @@
|
|||||||
export default defineNuxtConfig({
|
export default defineNuxtConfig({
|
||||||
modules: ['@nuxtjs/tailwindcss'],
|
modules: ['@nuxtjs/tailwindcss'],
|
||||||
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 +18,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 +29,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",
|
||||||
|
|||||||
+104
-17
@@ -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,11 @@
|
|||||||
</ClientOnly>
|
</ClientOnly>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Fond neutre pour l'onglet « Fiches » (pas de carte dessous) -->
|
||||||
|
<div v-show="mobileMapView === 'fiches'" class="absolute inset-0" style="background: var(--nav-bg-alt);" />
|
||||||
|
|
||||||
<!-- Bottom sheet swipable (masqué en vue graphique pour ne pas occulter le canvas) -->
|
<!-- Bottom sheet swipable (masqué en vue graphique pour ne pas occulter le canvas) -->
|
||||||
<ClientOnly v-if="mobileMapView !== 'graphe'">
|
<ClientOnly v-if="mobileMapView !== 'graphe' && mobileMapView !== 'fiches'">
|
||||||
<MobileSheet :resultCount="filtered.length" :pending="pending">
|
<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);">
|
||||||
@@ -364,6 +405,21 @@
|
|||||||
</div>
|
</div>
|
||||||
</MobileSheet>
|
</MobileSheet>
|
||||||
</ClientOnly>
|
</ClientOnly>
|
||||||
|
|
||||||
|
<!-- Bottom sheet — onglet « Fiches » mobile (B3-M3), contenu dédié -->
|
||||||
|
<ClientOnly v-else-if="mobileMapView === 'fiches'">
|
||||||
|
<MobileSheet :resultCount="filtered.length" :pending="pending">
|
||||||
|
<FichesPanel
|
||||||
|
:fiches="allStructuresMini"
|
||||||
|
:hashtags="allHashtags"
|
||||||
|
:selected="selectedHashtags"
|
||||||
|
:query="search"
|
||||||
|
intention="Toutes les agences et réseaux du répertoire, filtrables par hashtag."
|
||||||
|
@update:selected="selectedHashtags = $event"
|
||||||
|
@open="(fiche) => onSelectStructureMobile(String(fiche.id))"
|
||||||
|
/>
|
||||||
|
</MobileSheet>
|
||||||
|
</ClientOnly>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
@@ -454,15 +510,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 +637,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(() => [])
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
<!--
|
||||||
|
Page de dev — story de test FicheMiniCard (B3-M2)
|
||||||
|
Sert uniquement à la vérification visuelle. Aucun lien vers cette page
|
||||||
|
nulle part dans l'app (pas dans app.vue, pas dans les pages) : navigation
|
||||||
|
directe par URL uniquement (/dev/fiche-mini-card).
|
||||||
|
-->
|
||||||
|
<template>
|
||||||
|
<div class="story">
|
||||||
|
<h1 class="story__title">FicheMiniCard — story de test (B3-M2)</h1>
|
||||||
|
|
||||||
|
<section class="story__section">
|
||||||
|
<h2 class="story__heading">Org — cas nominal</h2>
|
||||||
|
<div class="story__grid">
|
||||||
|
<FicheMiniCard :fiche="orgNominal" @open="log('org-nominal')" />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="story__section">
|
||||||
|
<h2 class="story__heading">Structure — cas nominal</h2>
|
||||||
|
<div class="story__grid">
|
||||||
|
<FicheMiniCard :fiche="structureNominal" @open="log('structure-nominal')" />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="story__section">
|
||||||
|
<h2 class="story__heading">Cas limites</h2>
|
||||||
|
<div class="story__grid">
|
||||||
|
<FicheMiniCard :fiche="nomLong" @open="log('nom-long')" />
|
||||||
|
<FicheMiniCard :fiche="sansDescription" @open="log('sans-description')" />
|
||||||
|
<FicheMiniCard :fiche="sansChips" @open="log('sans-chips')" />
|
||||||
|
<FicheMiniCard :fiche="sansGeoloc" @open="log('sans-geoloc')" />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="story__section">
|
||||||
|
<h2 class="story__heading">Dernier événement « open »</h2>
|
||||||
|
<p class="story__log">{{ lastOpen ?? '(aucun clic pour le moment)' }}</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { MiniFiche } from '~/composables/useFicheAdapter'
|
||||||
|
|
||||||
|
definePageMeta({ layout: false })
|
||||||
|
|
||||||
|
const lastOpen = ref<string | null>(null)
|
||||||
|
function log(label: string) {
|
||||||
|
lastOpen.value = label
|
||||||
|
}
|
||||||
|
|
||||||
|
const orgNominal: MiniFiche = {
|
||||||
|
id: 1,
|
||||||
|
nom: 'Réseau Bâti Vivant',
|
||||||
|
resume: 'Un réseau national d\'accompagnement juridique et technique pour les collectifs d\'auto-construction.',
|
||||||
|
chips: ['Juridique', 'Technique'],
|
||||||
|
echelle: 'National',
|
||||||
|
geoloc: true,
|
||||||
|
kind: 'org',
|
||||||
|
}
|
||||||
|
|
||||||
|
const structureNominal: MiniFiche = {
|
||||||
|
id: 'f1-rotor',
|
||||||
|
nom: 'Rotor',
|
||||||
|
resume: 'Rotor invente en Belgique le protocole de la déconstruction sélective comme geste politique contre la culture du tout-neuf.',
|
||||||
|
chips: ['#reemploi-structurel', '#plateforme-materiaux'],
|
||||||
|
geoloc: true,
|
||||||
|
kind: 'structure',
|
||||||
|
}
|
||||||
|
|
||||||
|
const nomLong: MiniFiche = {
|
||||||
|
id: 2,
|
||||||
|
nom: 'Collectif Régional de Réemploi et d\'Accompagnement des Chantiers Participatifs du Grand Ouest',
|
||||||
|
resume: 'Nom volontairement long pour vérifier l\'ellipse sur une ligne.',
|
||||||
|
chips: ['Chantier'],
|
||||||
|
echelle: 'Régional',
|
||||||
|
geoloc: true,
|
||||||
|
kind: 'org',
|
||||||
|
}
|
||||||
|
|
||||||
|
const sansDescription: MiniFiche = {
|
||||||
|
id: 3,
|
||||||
|
nom: 'Structure sans description',
|
||||||
|
resume: '',
|
||||||
|
chips: ['Formation'],
|
||||||
|
echelle: 'Local',
|
||||||
|
geoloc: true,
|
||||||
|
kind: 'org',
|
||||||
|
}
|
||||||
|
|
||||||
|
const sansChips: MiniFiche = {
|
||||||
|
id: 4,
|
||||||
|
nom: 'Structure sans chips',
|
||||||
|
resume: 'Une organisation dont aucune fonction ni hashtag n\'est renseigné dans la source.',
|
||||||
|
chips: [],
|
||||||
|
geoloc: true,
|
||||||
|
kind: 'org',
|
||||||
|
}
|
||||||
|
|
||||||
|
const sansGeoloc: MiniFiche = {
|
||||||
|
id: 5,
|
||||||
|
nom: 'Structure sans localisation',
|
||||||
|
resume: 'Une fiche nationale sans latitude ni longitude — invisible sur la carte, visible ici.',
|
||||||
|
chips: ['Administratif', 'Comptabilité'],
|
||||||
|
echelle: 'National',
|
||||||
|
geoloc: false,
|
||||||
|
kind: 'org',
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.story {
|
||||||
|
max-width: 1100px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: var(--space-lg);
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: var(--font-body);
|
||||||
|
}
|
||||||
|
|
||||||
|
.story__title {
|
||||||
|
font-size: var(--fs-xl);
|
||||||
|
font-weight: var(--fw-bold);
|
||||||
|
margin-bottom: var(--space-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.story__section {
|
||||||
|
margin-bottom: var(--space-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.story__heading {
|
||||||
|
font-size: var(--fs-sm);
|
||||||
|
font-weight: var(--fw-semibold);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.story__grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||||
|
gap: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.story__log {
|
||||||
|
font-size: var(--fs-sm);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
+104
-30
@@ -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,9 +193,12 @@
|
|||||||
</ClientOnly>
|
</ClientOnly>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Bottom sheet swipable (Métropole et Outre-mer) -->
|
<!-- Fond neutre pour l'onglet « Fiches » (pas de carte dessous) -->
|
||||||
|
<div v-show="mobileMapView === 'fiches'" class="absolute inset-0" style="background: var(--nav-bg-alt);" />
|
||||||
|
|
||||||
|
<!-- Bottom sheet swipable (Métropole, Outre-mer et Fiches) -->
|
||||||
<ClientOnly>
|
<ClientOnly>
|
||||||
<MobileSheet :resultCount="filtered.length" :pending="pending">
|
<MobileSheet v-if="mobileMapView !== 'fiches'" :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);">
|
||||||
<label class="mobile-search-label" aria-label="Rechercher une organisation">
|
<label class="mobile-search-label" aria-label="Rechercher une organisation">
|
||||||
@@ -185,36 +227,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
|
||||||
@@ -272,6 +314,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</MobileSheet>
|
</MobileSheet>
|
||||||
|
|
||||||
|
<!-- Bottom sheet — onglet « Fiches » mobile (B3-M3), contenu dédié -->
|
||||||
|
<MobileSheet v-else :resultCount="filtered.length" :pending="pending">
|
||||||
|
<FichesPanel
|
||||||
|
:fiches="allFichesMini"
|
||||||
|
:hashtags="[...FONCTIONS]"
|
||||||
|
:selected="fonctions"
|
||||||
|
:query="search"
|
||||||
|
intention="Toutes les structures d'entraide, y compris celles sans adresse sur la carte. Cochez une fonction pour filtrer."
|
||||||
|
@update:selected="onFonctions"
|
||||||
|
@open="(fiche) => onSelectOrgMobile(Number(fiche.id))"
|
||||||
|
/>
|
||||||
|
</MobileSheet>
|
||||||
</ClientOnly>
|
</ClientOnly>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -358,12 +413,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 +468,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 +609,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
-6
@@ -137,15 +137,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 +228,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, souverain, 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,7 +684,112 @@ useSeoMeta({
|
|||||||
margin: 0 -0.5rem;
|
margin: 0 -0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fmhy-tree-wrap {
|
/* ── Note FMHY détaillée (par usage) ────────────────────────── */
|
||||||
|
.fmhy-usage {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fmhy-usage__title {
|
||||||
|
font-size: 0.92rem;
|
||||||
|
font-weight: 650;
|
||||||
|
color: var(--nav-text);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fmhy-usage__note {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--nav-text-muted);
|
||||||
|
font-style: italic;
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fmhy-usage__list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fmhy-usage__item {
|
||||||
|
font-size: 0.84rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fmhy-usage__item:hover {
|
||||||
|
background: var(--nav-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fmhy-usage__link {
|
||||||
|
color: var(--nav-primary-solid);
|
||||||
|
font-weight: 500;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fmhy-usage__link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fmhy-usage__desc {
|
||||||
|
color: var(--nav-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fmhy-warning {
|
||||||
|
background: var(--nav-surface);
|
||||||
|
border: 1px solid var(--nav-bg-alt);
|
||||||
|
border-left: 3px solid var(--nav-primary-solid);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.875rem 1rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fmhy-warning__title {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 650;
|
||||||
|
color: var(--nav-text);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fmhy-warning__text {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--nav-text-muted);
|
||||||
|
line-height: 1.6;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fmhy-warning__text a {
|
||||||
|
color: var(--nav-primary-solid);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fmhy-snapshot {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--nav-text-muted);
|
||||||
|
font-style: italic;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fmhy-snapshot a {
|
||||||
|
color: var(--nav-primary-solid);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fmhy-tree-label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--nav-text-muted);
|
||||||
|
margin: 0.5rem 0 -0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fmhy-tree-wrap {
|
||||||
padding: 0.875rem 0.75rem;
|
padding: 0.875rem 0.75rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
}
|
||||||
+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: {
|
||||||
|
|||||||
Reference in New Issue
Block a user