feat(aep): carte AEP — push Gitea 2026-04-28
This commit is contained in:
9
.gitignore
vendored
Normal file
9
.gitignore
vendored
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
node_modules
|
||||||
|
.nuxt
|
||||||
|
.output
|
||||||
|
dist
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
*.log
|
||||||
|
|
||||||
|
*.tmp.*
|
||||||
133
Archive Prompts/dev-aep-S6-corrections-post-S5.md
Normal file
133
Archive Prompts/dev-aep-S6-corrections-post-S5.md
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
# Prompt — AEP Session 6 (corrections post S5)
|
||||||
|
|
||||||
|
**À lancer :** prochaine session, Sonnet full auto (Opus pilote standby)
|
||||||
|
**Priorité :** Mobile polish + DOM-TOM layout repensé + bug form proposer
|
||||||
|
**Créé :** 2026-04-15 (après S5 livrée — 11/11 retours, 8 commits)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Contexte
|
||||||
|
|
||||||
|
Site live : https://aep.trans-former.fr/
|
||||||
|
Racine projet : `C:\Users\jules\Dropbox\ATIS - IPCJRA\1 PROJETS\TECH - infra VPS, website pro, RAG\nav-carte\`
|
||||||
|
VPS SSH : alias `vps-hetzner`, service `nav-carte.service`, working dir `/opt/nav-carte/.output/`.
|
||||||
|
Stack : Nuxt 3 + Vue 3 + Tailwind + Leaflet.
|
||||||
|
|
||||||
|
**État S5 livrée (2026-04-15) :**
|
||||||
|
- 11 retours implémentés (onglets header, sheet mobile, report Resend, DOM-TOM row bas, dark mode tuile, bandeau inversé, etc.)
|
||||||
|
- 8 commits `aep-s5`
|
||||||
|
- Site 200 OK, service active
|
||||||
|
|
||||||
|
**NE PAS TOUCHER à `.env` VPS ni `.env.production` local** (patchés manuellement).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Mission
|
||||||
|
|
||||||
|
9 retours UX post-S5 + 1 bug critique (form proposer invisible).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Retours Jules — à implémenter
|
||||||
|
|
||||||
|
### 1. DOM-TOM — repenser le layout (encore)
|
||||||
|
**Actuel S5 :** row horizontale pleine largeur en bas de la Métropole (Option A).
|
||||||
|
**Cible S6 :** DOM-TOM à droite du site, **empilés verticalement** (superposés). Navigation dans chaque mini-carte OK (acquis S5).
|
||||||
|
|
||||||
|
En clair : revenir sur un encart à droite de la Métropole, mais les 5 DOM-TOM se **superposent** (une pile verticale dense), pas en grille étalée. Conserver `dragging: true` + `scrollWheelZoom: true`.
|
||||||
|
|
||||||
|
**Layout suggéré :** Métropole 75-80% largeur à gauche, colonne droite 20-25% avec les 5 DOM-TOM empilés (Guadeloupe, Martinique, Guyane, Réunion, Mayotte) — petites hauteurs ~140-160px chacune.
|
||||||
|
|
||||||
|
**Bug #1a — Réunion** : la mini-carte Réunion disparaît après un moment de déplacement. À investiguer (event listener cassé ? tile layer perdu ? bounds reset ?).
|
||||||
|
|
||||||
|
### 2. Onglets header — style "languette de dossier"
|
||||||
|
Les 3 onglets (Écosystème / Agences / RAG) sont présents mais trop discrets.
|
||||||
|
Ajouter un effet visuel **languette de fiche de dossier** : petit trapèze/rectangle qui ressort au-dessus de la carte, suggère que l'onglet actif "tire" le contenu de la carte vers le haut. Underline + border-top + border-left/right arrondis haut, background blanc pour l'actif, fond gris clair pour les inactifs.
|
||||||
|
|
||||||
|
### 3. Mobile — onglets invisibles
|
||||||
|
Les 3 onglets ne sont pas visibles en version mobile actuellement. Les faire apparaître (scroll horizontal ou drawer compact). Même style languette que desktop, adapté petit écran.
|
||||||
|
|
||||||
|
### 4. Mobile — bug barre recherche superposée
|
||||||
|
La barre de recherche header mobile chevauche les filtres "National / Local". Fix : soit décaler la barre, soit décaler les filtres, soit les combiner proprement dans un bloc unifié.
|
||||||
|
|
||||||
|
### 5. Mobile — supprimer ou remonter "Leaflet | OpenStreetMap contributors"
|
||||||
|
La ligne de crédit Leaflet en bas de la carte interfère avec les fiches qui remontent (sheet swipable). Options :
|
||||||
|
- Option A : supprimer sur mobile (`attributionControl: false`)
|
||||||
|
- Option B : la déplacer en haut de la carte (`position: 'topright'` — mais conflit possible avec autres UI)
|
||||||
|
- **Recommandation agent : Option A** (suppression mobile — la mention légale peut vivre dans `/a-propos`).
|
||||||
|
|
||||||
|
### 6. Mobile — remonter le chatbot, coeur reste en bas
|
||||||
|
Actuellement chatbot et coeur (don) tous deux en bas, ça prend de la place inutile.
|
||||||
|
Cible :
|
||||||
|
- **Chatbot** : remonter en haut (icône flottante top-right ou intégrée au header)
|
||||||
|
- **Coeur (don Liberapay)** : reste en bas à droite à la place du chatbot (garde visibilité du don, important)
|
||||||
|
|
||||||
|
### 7. Desktop — bandeau bas label initial
|
||||||
|
Actuel replié : "AEP transparence IA"
|
||||||
|
Cible replié : **"Soutenir le projet"** (plus clair, plus engageant)
|
||||||
|
Transparence IA passe en second plan (hover ou dans le bandeau déployé).
|
||||||
|
|
||||||
|
### 8. Desktop — bandeau fluidité
|
||||||
|
Le bandeau déploie/replie pas hyper fluide. Ajouter transition CSS propre : `transition: height 0.25s ease-out, opacity 0.2s ease-out`. Vérifier qu'il n'y a pas de reflow layout (utiliser `transform: translateY` si possible plutôt que `height`).
|
||||||
|
|
||||||
|
### 9. Formulaire "Proposer une ressource" — nombre de caractères
|
||||||
|
Réduire les minimums de caractères requis. Accepter même 0 ou très court (1 ligne suffit). Laisser la description optionnelle ou très permissive.
|
||||||
|
|
||||||
|
### 10. **BUG CRITIQUE — Bouton envoyer invisible sur /proposer**
|
||||||
|
Sur la page formulaire "Proposer une ressource", **le bouton "Envoyer" n'est pas visible** — probablement masqué sous le bandeau IA/footer fixe. Impossible de soumettre une fiche actuellement.
|
||||||
|
|
||||||
|
Fix :
|
||||||
|
- Ajouter un `padding-bottom` suffisant au form pour que le bouton soit au-dessus du bandeau
|
||||||
|
- Ou : s'assurer que le bandeau a un `z-index` inférieur au form
|
||||||
|
- Tester sur desktop ET mobile
|
||||||
|
|
||||||
|
### 11. Chatbot — vérifier compteur de tokens
|
||||||
|
Jules a fait quelques recherches via chatbot, mais le compteur de tokens n'a apparemment pas bougé. Vérifier que le tracking fonctionne (logs API, compteur frontend, incrémentation backend). Si bug, corriger.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Règles d'exécution
|
||||||
|
|
||||||
|
- Sonnet full auto, délègue aux sous-agents task si utile
|
||||||
|
- Commits atomiques : `feat(aep-s6): <feature>` / `fix(aep-s6): <bug>`
|
||||||
|
- Tests après chaque batch : `npm run build`
|
||||||
|
- Deploy final :
|
||||||
|
```bash
|
||||||
|
tar -czf - .output | ssh vps-hetzner "rm -rf /tmp/nav-output && mkdir /tmp/nav-output && tar -xzf - -C /tmp/nav-output && rm -rf /opt/nav-carte/.output && mv /tmp/nav-output/.output /opt/nav-carte/.output && systemctl restart nav-carte"
|
||||||
|
```
|
||||||
|
- Vérif : `curl -sI https://aep.trans-former.fr/ | head -3` → 200
|
||||||
|
- Mise à jour `JOURNAL-V2.md` section "Session 6 (2026-04-16)"
|
||||||
|
|
||||||
|
## Critères de fin
|
||||||
|
|
||||||
|
- [ ] 10 retours implémentés + #11 chatbot compteur vérifié
|
||||||
|
- [ ] Bug form proposer (bouton envoyer visible + soumission fonctionnelle) testé
|
||||||
|
- [ ] DOM-TOM à droite empilés + bug Réunion fixé
|
||||||
|
- [ ] Mobile : onglets visibles, barre recherche fixée, chatbot remonté, leaflet credit géré
|
||||||
|
- [ ] Site 200 OK, service active
|
||||||
|
- [ ] JOURNAL-V2.md à jour
|
||||||
|
|
||||||
|
## Modèle recommandé
|
||||||
|
|
||||||
|
- Sonnet full auto sur tout
|
||||||
|
- Opus pilote standby uniquement si bug Réunion #1a nécessite arbitrage debug
|
||||||
|
|
||||||
|
## Durée estimée
|
||||||
|
|
||||||
|
1h30-2h
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Autocritique
|
||||||
|
|
||||||
|
**✓ Solide :**
|
||||||
|
- Bugs critiques identifiés (form proposer, Réunion)
|
||||||
|
- Layout DOM-TOM reprécisé clairement (pile verticale à droite, pas row bas)
|
||||||
|
- Chaque retour a recommandation concrète
|
||||||
|
|
||||||
|
**⚠ Faiblesses :**
|
||||||
|
- #2 style "languette de dossier" : subjectif, agent devra itérer — si rendu pas satisfaisant, screenshot + retour session suivante
|
||||||
|
- #8 fluidité bandeau : solution CSS proposée mais dépend de l'archi actuelle de `BandeauBas.vue`
|
||||||
|
- #11 compteur tokens chatbot : pas clair si bug frontend ou backend — agent devra investiguer
|
||||||
|
|
||||||
|
**Autonomie : 8/10** (bug Réunion potentiellement piégeux, sinon clean).
|
||||||
60
Archive Prompts/dev-aep-S8-scroll-hamburger.md
Normal file
60
Archive Prompts/dev-aep-S8-scroll-hamburger.md
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
# Prompt AEP — S8 : Fix scroll fiches + hamburger desktop
|
||||||
|
|
||||||
|
**Projet :** `1 PROJETS/TECH - infra VPS, website pro, RAG/nav-carte/`
|
||||||
|
**Site live :** `aep.trans-former.fr`
|
||||||
|
**Invocation :** `/atis-dev AEP`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## État d'avancement (fin S8, 2026-04-25) — SESSION TERMINÉE
|
||||||
|
|
||||||
|
**S8 livré et déployé :**
|
||||||
|
- P0 scroll : `app.vue` — `overflow-hidden` conditionné sur `route.path === '/'`, `overflow-y-auto` ailleurs → /fiche/[id], /a-propos, /contribuer scrollent
|
||||||
|
- P1 hamburger : lien "Signaler" ajouté dans le header desktop (`hidden lg:inline-flex`) — accessible sans hamburger
|
||||||
|
- /a-propos : phrase "On s'installe seul.e." ajoutée en S1 (texte 2026-04-25 ATIS Business)
|
||||||
|
- Deploy : `/opt/aep/` sur VPS Hetzner, service `aep`, port 3333 — HTTP 200 confirmé
|
||||||
|
|
||||||
|
**Nota deploy :** le dossier VPS est `/opt/aep/` (pas `/opt/nav-carte/`), service systemd `aep`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## État d'avancement (fin S7, 2026-04-16)
|
||||||
|
|
||||||
|
Déployé et fonctionnel :
|
||||||
|
- Logo "AEP" dans le carré + sous-titre "Architecture d'Écologie Politique" + lien → trans-former.fr
|
||||||
|
- Onglets Agences/RAG masqués (code en commentaire dans app.vue, pages gardées)
|
||||||
|
- Mobile : clic fiche → navigation /fiche/[id] (filtres sauvegardés)
|
||||||
|
- Desktop : nextTick sur ficheModalOpen (P0 fix)
|
||||||
|
- Outre-mer mobile : accordéon (header compact, déplie 33vh, invalidateSize Leaflet)
|
||||||
|
- /a-propos : section "Coût de construction" (~1M tokens, ~0,3 kgCO₂e)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Reste à faire (S9+)
|
||||||
|
|
||||||
|
- /a-propos Section 5 "Transparence IA" — placeholder à écrire (posture politique des coûts IA)
|
||||||
|
- /a-propos Section 6 "Contribuer" — placeholder à écrire (invitation, ton sans friction)
|
||||||
|
- Taxonomie v2 (Session N dans roadmap dev)
|
||||||
|
- Intégration IA / RAG chatbot (Session S3)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fichiers clés
|
||||||
|
|
||||||
|
| Fichier | Rôle |
|
||||||
|
|---------|------|
|
||||||
|
| `app.vue` | Layout global, header, overflow conditionné |
|
||||||
|
| `pages/index.vue` | Page carte (overflow-hidden géré via app.vue conditionnel) |
|
||||||
|
| `pages/a-propos.vue` | Page à propos — sections 5+6 encore placeholder |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Deploy (corrigé)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd nav-carte && npm run build
|
||||||
|
tar -czf /tmp/nav-carte-output.tar.gz -C .output .
|
||||||
|
scp -P 4422 /tmp/nav-carte-output.tar.gz vps-hetzner:/tmp/
|
||||||
|
ssh -p 4422 vps-hetzner "rm -rf /opt/aep/server /opt/aep/public && tar -xzf /tmp/nav-carte-output.tar.gz -C /opt/aep && rm /tmp/nav-carte-output.tar.gz && systemctl restart aep"
|
||||||
|
ssh -p 4422 vps-hetzner "curl -s -o /dev/null -w 'HTTP %{http_code}' http://localhost:3333/"
|
||||||
|
```
|
||||||
1005
JOURNAL-V2.md
Normal file
1005
JOURNAL-V2.md
Normal file
File diff suppressed because it is too large
Load Diff
332
PIPE-IA-DOC.md
Normal file
332
PIPE-IA-DOC.md
Normal file
@@ -0,0 +1,332 @@
|
|||||||
|
---
|
||||||
|
type: documentation
|
||||||
|
project: NAV V2
|
||||||
|
created: 2026-04-14
|
||||||
|
status: validé
|
||||||
|
session: S3
|
||||||
|
---
|
||||||
|
|
||||||
|
# PIPE-IA-DOC — Pipeline enrichissement IA NAV V2
|
||||||
|
|
||||||
|
Documentation précise du pipeline IA d'enrichissement des fiches NAV. Base pour le futur skill `/mistral-nemo-vps`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Vue d'ensemble
|
||||||
|
|
||||||
|
```
|
||||||
|
Fiche soumise (moderation_status=pending, ai_processed=false)
|
||||||
|
↓
|
||||||
|
[WORKER] toutes les 5 min via systemd timer
|
||||||
|
↓
|
||||||
|
SCRAPING — crawl4ai AsyncHTTPCrawlerStrategy (mode statique, sans Playwright)
|
||||||
|
↓
|
||||||
|
TRUNCATURE — 16 000 chars max (~4 000 tokens)
|
||||||
|
↓
|
||||||
|
MISTRAL NEMO — open-mistral-nemo, temp=0.2, max_tokens=800, json_object
|
||||||
|
↓
|
||||||
|
NORMALISATION TAGS — mapping taxonomie interne
|
||||||
|
↓
|
||||||
|
UPDATE NocoDB — description_enrichie, points_cles, tags_fonction, moderation_status=ai_processed
|
||||||
|
↓
|
||||||
|
LOG stats_usage — tokens_in, tokens_out, cout_eur, orga_id
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Input — Schéma fiche entrant
|
||||||
|
|
||||||
|
### Champs lus depuis NocoDB (table `organisations`, `m08t7g5v4wch6wb`)
|
||||||
|
|
||||||
|
| Champ | Type | Rôle dans la pipe |
|
||||||
|
|-------|------|------------------|
|
||||||
|
| `Id` | int | Identifiant unique, passé à stats_usage comme orga_id |
|
||||||
|
| `nom` | text | Passé dans le user prompt |
|
||||||
|
| `url` | text | URL à scraper (si présente et scrape_status=pending) |
|
||||||
|
| `description` / `description_user` | longtext | Fallback si scrape échoué ou URL absente |
|
||||||
|
| `echelle` | select | Passé au prompt pour contexte |
|
||||||
|
| `scrape_status` | select | Détermine si on scrape (pending) ou non |
|
||||||
|
| `ai_processed` | checkbox | false = à traiter |
|
||||||
|
| `moderation_status` | select | pending = à traiter |
|
||||||
|
|
||||||
|
### Filtre NocoDB
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/v1/db/data/noco/{BASE}/{TABLE}?
|
||||||
|
where=(moderation_status,eq,pending)~and(ai_processed,eq,false)
|
||||||
|
&limit=5
|
||||||
|
&sort=submitted_at
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Scraping — crawl4ai mode HTTP statique
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
```python
|
||||||
|
from crawl4ai import AsyncWebCrawler, CrawlerRunConfig
|
||||||
|
from crawl4ai.async_crawler_strategy import AsyncHTTPCrawlerStrategy
|
||||||
|
|
||||||
|
strategy = AsyncHTTPCrawlerStrategy()
|
||||||
|
run_cfg = CrawlerRunConfig(
|
||||||
|
word_count_threshold=20,
|
||||||
|
excluded_tags=['nav', 'footer', 'script', 'style', 'head'],
|
||||||
|
remove_overlay_elements=True
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Points clés
|
||||||
|
|
||||||
|
- Mode **statique uniquement** — pas de Playwright, pas de Chrome (Playwright non installé sur le VPS Hetzner CAX11)
|
||||||
|
- Timeout : **3 minutes** (spawnSync avec timeout=180 000 ms)
|
||||||
|
- Troncature : **16 000 chars max** après récupération (~4 000 tokens Nemo)
|
||||||
|
- Fallback : si échec, flag `scrape_status=failed` et appel Mistral avec `description_user` seule
|
||||||
|
- Script Python exécuté via `spawnSync('python3', [scriptPath])` depuis Node.js
|
||||||
|
|
||||||
|
### Limitations connues
|
||||||
|
|
||||||
|
- Les SPAs (Angular, React sans SSR) peuvent retourner du HTML vide → `scrape_status=failed`
|
||||||
|
- Les sites avec RGPD wall (consent redirect) → `scrape_status=failed`
|
||||||
|
- Crawl4ai 0.8.6 sur VPS : mode statique uniquement. Si besoin de JS-rendering → installer Playwright (`playwright install chromium`) et basculer sur `AsyncWebCrawler` standard.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Appel Mistral Nemo — Prompt exact
|
||||||
|
|
||||||
|
### Paramètres API
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"model": "open-mistral-nemo",
|
||||||
|
"temperature": 0.2,
|
||||||
|
"max_tokens": 800,
|
||||||
|
"response_format": { "type": "json_object" }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### System prompt (copie exacte, source F §3)
|
||||||
|
|
||||||
|
```
|
||||||
|
Tu es un assistant spécialisé dans l'écosystème professionnel de l'architecture en France. Tu reçois des informations sur une organisation ou ressource liée au secteur de l'architecture, et tu dois les enrichir pour alimenter une cartographie collaborative.
|
||||||
|
|
||||||
|
RÈGLES ABSOLUES :
|
||||||
|
1. Tu ne dois JAMAIS inventer d'informations non présentes dans les sources fournies.
|
||||||
|
2. Si une information est absente ou incertaine, retourne `null` pour ce champ.
|
||||||
|
3. Tu dois retourner UNIQUEMENT un objet JSON valide, sans texte avant ou après.
|
||||||
|
4. La description_enrichie doit être neutre, factuelle, en français, sans jugement de valeur.
|
||||||
|
5. Les points_cles sont des phrases courtes (max 12 mots chacune), actionnables pour un architecte.
|
||||||
|
6. Pour les tags_fonction, ne propose que des valeurs parmi la liste autorisée.
|
||||||
|
|
||||||
|
TAXONOMIE AUTORISÉE :
|
||||||
|
- Échelle (une seule valeur) : "National" | "Régional" | "Départemental" | "Local"
|
||||||
|
- Territoire (une seule valeur) : "Métropole" | "Guadeloupe" | "Martinique" | "Guyane" | "Réunion" | "Mayotte" | null
|
||||||
|
- Tags fonction (1 à 5 valeurs) : "Juridique" | "Technique" | "Économique" | "Administratif" | "Chantier" | "Comptabilité" | "Développement" | "Formation" | "Gestion d'agence" | "Santé mentale"
|
||||||
|
|
||||||
|
FORMAT DE SORTIE JSON :
|
||||||
|
{
|
||||||
|
"description_enrichie": "string (max 300 chars, français, neutre, factuel)",
|
||||||
|
"points_cles": ["string", "string", "string"],
|
||||||
|
"tags_fonction": ["Valeur1", "Valeur2"],
|
||||||
|
"echelle": "National" | "Régional" | "Départemental" | "Local" | null,
|
||||||
|
"territoire": "Métropole" | ... | null,
|
||||||
|
"localisation_ville": "string" | null,
|
||||||
|
"confiance": "haute" | "moyenne" | "faible"
|
||||||
|
}
|
||||||
|
|
||||||
|
Le champ "confiance" reflète ta certitude globale sur l'enrichissement :
|
||||||
|
- "haute" : URL scrapée avec contenu riche, informations claires
|
||||||
|
- "moyenne" : URL scrapée mais contenu partiel, ou description_user seule suffisante
|
||||||
|
- "faible" : URL non disponible et description_user vague, inférences importantes
|
||||||
|
```
|
||||||
|
|
||||||
|
### User prompt (template)
|
||||||
|
|
||||||
|
```
|
||||||
|
ORGANISATION À ENRICHIR :
|
||||||
|
|
||||||
|
Nom : {{nom}}
|
||||||
|
URL : {{url_ou_"non fournie"}}
|
||||||
|
Description soumise par l'utilisateur : {{description_user_ou_"non fournie"}}
|
||||||
|
|
||||||
|
CONTENU DU SITE WEB (extrait par scraping) :
|
||||||
|
{{scrape_content_ou_"Site non accessible ou URL non fournie."}}
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Enrichis cette fiche selon les règles du system prompt. Retourne uniquement le JSON.
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Output — Champs mis à jour dans NocoDB
|
||||||
|
|
||||||
|
| Champ NocoDB | Source | Exemple |
|
||||||
|
|-------------|--------|---------|
|
||||||
|
| `description_enrichie` | JSON IA `.description_enrichie` | "Le CNOA est un organisme réglementaire..." |
|
||||||
|
| `points_cles` | JSON.stringify(IA `.points_cles`) | `["Représenter les architectes","..."]` |
|
||||||
|
| `tags_fonction` | Tags normalisés, joint par virgule | `"Juridique,Administratif"` |
|
||||||
|
| `echelle` | IA `.echelle` (si non renseignée) | `"National"` |
|
||||||
|
| `territoire` | IA `.territoire` (si non renseignée) | `"Métropole"` |
|
||||||
|
| `localisation_ville` | IA `.localisation_ville` (si vide) | `"Paris"` |
|
||||||
|
| `moderation_status` | Fixé à `"ai_processed"` | — |
|
||||||
|
| `ai_processed` | Fixé à `true` | — |
|
||||||
|
| `ai_raw_output` | JSON.stringify complet du retour IA | Debug |
|
||||||
|
| `scrape_status` | `"scraped"` / `"failed"` / `"no_link"` | — |
|
||||||
|
| `scrape_content` | Markdown brut crawl4ai | Stocké pour debug |
|
||||||
|
|
||||||
|
### Normalisation tags
|
||||||
|
|
||||||
|
Les tags retournés par l'IA sont normalisés via un mapping interne avant insertion.
|
||||||
|
L'apostrophe `'` (U+0027) est convertie en `'` (U+2019) pour compatibilité NocoDB.
|
||||||
|
Tags non reconnus → ignorés silencieusement.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Circuit breaker budget
|
||||||
|
|
||||||
|
### Calcul coût Mistral Nemo
|
||||||
|
|
||||||
|
```
|
||||||
|
cout_eur = ((tokens_in × 0.02 + tokens_out × 0.04) / 1_000_000) × 0.93
|
||||||
|
```
|
||||||
|
|
||||||
|
(Prix USD/1M tokens × taux USD→EUR fixé à 0.93)
|
||||||
|
|
||||||
|
### Paliers
|
||||||
|
|
||||||
|
| Seuil | Action |
|
||||||
|
|-------|--------|
|
||||||
|
| ≥ €20 | Hard stop, email Jules, worker en pause |
|
||||||
|
| Budget OK | Vérification avant chaque fiche |
|
||||||
|
|
||||||
|
### Limitation connue
|
||||||
|
|
||||||
|
Le filtre NocoDB par date (`gte,YYYY-MM-DD`) n'est pas supporté en v0.301.5. Contournement : récupération de tous les records stats_usage (limit=1000) et filtre JavaScript par mois/année.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Infrastructure
|
||||||
|
|
||||||
|
### Fichiers
|
||||||
|
|
||||||
|
| Fichier | Chemin VPS | Description |
|
||||||
|
|---------|-----------|-------------|
|
||||||
|
| Worker | `/opt/nav-carte/worker/enrich.js` | Script Node.js principal |
|
||||||
|
| Config | `/opt/nav-carte/.env` | Variables (chmod 600) |
|
||||||
|
| Lock | `/tmp/nav-worker.lock` | Anti-overlap |
|
||||||
|
| Timer | `/etc/systemd/system/nav-worker.timer` | Cron 5 min |
|
||||||
|
| Service | `/etc/systemd/system/nav-worker.service` | Oneshot systemd |
|
||||||
|
|
||||||
|
### Variables .env utilisées
|
||||||
|
|
||||||
|
```
|
||||||
|
MISTRAL_API_KEY=...
|
||||||
|
NOCODB_URL=http://localhost:8070
|
||||||
|
NOCODB_TOKEN=...
|
||||||
|
NOCODB_BASE=pipilvsi7dibo80
|
||||||
|
NOCODB_TABLE_ORGAS=m08t7g5v4wch6wb
|
||||||
|
NOCODB_TABLE_STATS=mbbq7n47ixy19mc
|
||||||
|
RESEND_API_KEY=...
|
||||||
|
RESEND_FROM=contact@trans-former.fr
|
||||||
|
EMAIL_JULES=jules@trans-former.fr
|
||||||
|
BUDGET_MAX_EUR=20
|
||||||
|
WORKER_LIMIT=5
|
||||||
|
```
|
||||||
|
|
||||||
|
### Commandes utiles
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Voir les logs du worker
|
||||||
|
journalctl -u nav-worker.service -n 50 --no-pager
|
||||||
|
|
||||||
|
# Voir le timer
|
||||||
|
systemctl status nav-worker.timer
|
||||||
|
|
||||||
|
# Lancer manuellement
|
||||||
|
cd /opt/nav-carte/worker && node --env-file=/opt/nav-carte/.env enrich.js
|
||||||
|
|
||||||
|
# Lancer avec limite custom
|
||||||
|
WORKER_LIMIT=1 node --env-file=/opt/nav-carte/.env enrich.js
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Résultats des 3 fiches test (Session 3 — 2026-04-14)
|
||||||
|
|
||||||
|
### Métriques
|
||||||
|
|
||||||
|
| Fiche | NocoDB Id | Scrape | tokens_in | tokens_out | cout_eur | Temps | Confiance |
|
||||||
|
|-------|-----------|--------|-----------|------------|----------|-------|-----------|
|
||||||
|
| CNOA | 106 | 1 506 chars | 1 248 | 167 | €0.000029 | 3.0s | haute |
|
||||||
|
| Archireport | 107 | 13 414 chars | 4 376 | 261 | €0.000091 | 3.9s | haute |
|
||||||
|
| Collectif Fil | 108 | 6 521 chars | 2 628 | 221 | €0.000057 | 4.3s | haute |
|
||||||
|
| **Total** | — | — | **8 252** | **649** | **€0.000177** | **11.2s** | — |
|
||||||
|
|
||||||
|
**Extrapolation 96 fiches :** €0.000177 × (96/3) = **€0.0057** total (très loin du seuil €1)
|
||||||
|
|
||||||
|
### Qualité des enrichissements
|
||||||
|
|
||||||
|
**CNOA (qualité : 4/5)**
|
||||||
|
|
||||||
|
- `description_enrichie` : neutre, factuelle, 200 chars. Correct.
|
||||||
|
- `points_cles` : 4 items. Pertinents mais génériques (niveau d'abstraction élevé).
|
||||||
|
- `tags_fonction` : Juridique, Administratif. L'IA a conservé uniquement les tags justifiés par le contenu scraped (règle "ne pas inventer" respectée). Manque "Gestion d'agence" qui était dans la fiche Jules mais pas dans le contenu scraped.
|
||||||
|
- Observation : le site architectes.org retourne peu de contenu (navigation + accroche = 1 506 chars). Performance correcte compte tenu du contexte limité.
|
||||||
|
|
||||||
|
**Archireport (qualité : 5/5)**
|
||||||
|
|
||||||
|
- `description_enrichie` : précise, factuelle, 302 chars. Très bonne.
|
||||||
|
- `points_cles` : 7 items très actionnables pour un architecte (gestion réserves, rapports, lots).
|
||||||
|
- `tags_fonction` : Technique, Administratif, Chantier. Pertinent. L'IA a ajouté "Administratif" non présent dans la fiche Jules → justifié (diffusion rapports de chantier = dimension administrative).
|
||||||
|
- Scrape : 13 414 chars = contenu riche. Nemo a bien distillé.
|
||||||
|
|
||||||
|
**Collectif Fil (qualité : 4/5)**
|
||||||
|
|
||||||
|
- `description_enrichie` : riche, capture l'esprit du collectif (architecture, urbanisme, participatif). 310 chars (légèrement au-dessus de 300, acceptable).
|
||||||
|
- `points_cles` : 3 items très qualitatifs (urbanisme participatif, méthodologies, co-construction).
|
||||||
|
- `tags_fonction` : Juridique, Technique, Économique, Administratif, Formation. 5 tags — un peu large. "Juridique" et "Économique" sont discutables pour un collectif de recherche-action. À revoir lors de la modération.
|
||||||
|
- Observation : l'IA semble sur-tagger quand le contenu est riche et varié. Acceptable — Jules valide en modération.
|
||||||
|
|
||||||
|
### Analyse comparative fonctions Jules vs IA
|
||||||
|
|
||||||
|
| Fiche | Fonctions Jules (seed) | Fonctions IA | Delta |
|
||||||
|
|-------|----------------------|-------------|-------|
|
||||||
|
| CNOA | Juridique, Administratif, Gestion d'agence | Juridique, Administratif | IA perd Gestion d'agence (non dans scrape) |
|
||||||
|
| Archireport | Chantier, Technique | Technique, Administratif, Chantier | IA ajoute Administratif (justifié) |
|
||||||
|
| Collectif Fil | Technique, Développement | Juridique, Technique, Économique, Administratif, Formation | IA sur-tague (5/5) |
|
||||||
|
|
||||||
|
**Observation générale :** l'IA est conservatrice sur les fiches avec peu de contenu (bon comportement) et peut sur-tagger avec du contenu riche. Le workflow Jules valide en modération NocoDB UI est adapté.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Problèmes rencontrés et solutions
|
||||||
|
|
||||||
|
| Problème | Solution appliquée |
|
||||||
|
|---------|-------------------|
|
||||||
|
| Playwright absent sur VPS → crawl4ai crash | Utilisation de `AsyncHTTPCrawlerStrategy` (mode statique) |
|
||||||
|
| NocoDB rejette filtres datetime ISO | Récupération de tous les records + filtre JavaScript par mois |
|
||||||
|
| NocoDB rejette apostrophe U+0027 dans tags | Utilisation apostrophe typographique U+2019 partout |
|
||||||
|
| `import('child_process')` dynamique en ESM | Remplacement par `spawnSync` importé statiquement en haut |
|
||||||
|
| Budget check fonctionne mais log "Erreur" | Corrigé dans version finale |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Base skill `/mistral-nemo-vps` (future)
|
||||||
|
|
||||||
|
Ce pipeline est documenté pour servir de base au skill `/mistral-nemo-vps` :
|
||||||
|
|
||||||
|
- **Entrée :** fichier texte ou URL → scraping crawl4ai → prompt enrichissement
|
||||||
|
- **Sortie :** JSON structuré (description, points clés, tags, confiance)
|
||||||
|
- **Paramètres configurables :** model, temperature, max_tokens, taxonomie, seuil confiance
|
||||||
|
- **Réutilisable pour :** audit fiche archi, résumé document, tagging automatique
|
||||||
|
|
||||||
|
Pattern à généraliser :
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Scrape(url) → markdown tronqué
|
||||||
|
2. Prompt(system_prompt, user_prompt_template, fiche_data, scrape_content)
|
||||||
|
3. Parse(json_response) → champs structurés
|
||||||
|
4. Normalize(tags) → taxonomie contrôlée
|
||||||
|
5. Log(usage) → stats_usage
|
||||||
|
```
|
||||||
134
V2-cadrage/A-biblio-ecosysteme-archi.md
Normal file
134
V2-cadrage/A-biblio-ecosysteme-archi.md
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
# NAV — Biblio seed écosystème architecture FR
|
||||||
|
|
||||||
|
Date : 2026-04-14
|
||||||
|
Nombre d'entités : 94
|
||||||
|
Méthode : recherche web (WebSearch + WebFetch) + connaissance modèle
|
||||||
|
|
||||||
|
## Résumé
|
||||||
|
|
||||||
|
- Couverture par typologie : 8 nat-institutionnel / 6 nat-syndicats / 6 nat-assos / 17 régional-CAUE-MA / 12 médias-revues / 6 podcasts-newsletters / 8 formations / 8 outils-plateformes / 10 santé-juridique-compta / 7 recrutement-RH / 6 entraide-solidaire
|
||||||
|
- Couverture par fonction : Juridique (bon) / Technique (bon) / Économique (moyen) / Administrative-urbanism (bon) / Chantier-coordo (moyen) / Comptabilité-fiscal (moyen) / Prospection-commercial (faible) / RH-recrutement (moyen) / Santé mentale (couvert généraliste, faible spécifique archi)
|
||||||
|
- Zones peu couvertes : newsletters business archi indépendants, ressources pricing honoraires, outils BIM accessibles, associations régionales hors grandes régions, numérique en archi (BIM Level 2-3), accompagnement création d'agence régional
|
||||||
|
|
||||||
|
## Tableau complet
|
||||||
|
|
||||||
|
| Nom | URL | Typologie | Échelle | Fonction principale | Localisation | Description (1 ligne) |
|
||||||
|
|-----|-----|-----------|---------|--------------------|--------------|--------------------|
|
||||||
|
| CNOA — Conseil National de l'Ordre des Architectes | https://www.architectes.org | Ordre | National | Institutionnel + juridique + déontologie | Paris | Corps réglementaire regroupant les 30 500 architectes inscrits ; registre national, déontologie, formations, ressources juridiques |
|
||||||
|
| CROA Île-de-France | https://www.architectes-idf.org | Ordre régional | Régional | Juridique + entraide + info | Paris | Conseil régional IDF — réponses juridiques gratuites, médiation, réseau des architectes |
|
||||||
|
| CROA PACA | https://www.ordrearchitectes-paca.fr | Ordre régional | Régional | Institutionnel + accompagnement | Marseille | Conseil régional Provence-Alpes-Côte d'Azur |
|
||||||
|
| CROA Hauts-de-France | https://www.architectes-hdf.fr | Ordre régional | Régional | Institutionnel + accompagnement | Lille | Conseil régional Hauts-de-France |
|
||||||
|
| CROA Auvergne-Rhône-Alpes | https://www.architectes-ara.fr | Ordre régional | Régional | Institutionnel + accompagnement | Lyon | Conseil régional AuRA — formations, veille, accompagnement |
|
||||||
|
| CROA Bretagne | https://www.architectes-bretagne.fr | Ordre régional | Régional | Institutionnel + accompagnement | Rennes | Conseil régional Bretagne |
|
||||||
|
| CROA Nouvelle-Aquitaine | https://www.bordeaux.architectes.org | Ordre régional | Régional | Institutionnel + accompagnement | Bordeaux | Conseil régional Nouvelle-Aquitaine |
|
||||||
|
| CROA Grand Est | https://www.architectes-grandest.fr | Ordre régional | Régional | Institutionnel + accompagnement | Strasbourg | Conseil régional Grand Est |
|
||||||
|
| UNSFA — Union Nationale des Syndicats Français d'Architectes | https://www.unsfa.fr | Syndicat patronal | National | Défense profession + employeurs + négociation CCN | Paris | Fédère syndicats territoriaux depuis 1969 ; représentation prud'homale, CCN, commissions paritaires |
|
||||||
|
| Le Syndicat de l'Architecture | https://syndicatdelarchitecture.com | Syndicat | National | Défense profession + accompagnement jeunes agences | Paris | Syndicat indépendant depuis 40 ans ; conseil juridique, incubateurs Échelle Un et SANA |
|
||||||
|
| SNACG — Syndicat National des Architectes Contractants Généraux | https://www.snacg.fr | Syndicat | National | Défense architecte contractant général | Paris | Défense et promotion du modèle architecte-constructeur clé en main ; affilié UNSFA |
|
||||||
|
| SYNATPAU — Syndicat Salariés Architecture Urbanisme | https://www.synatpau.fr | Syndicat salariés | National | Droit du travail + salariés archi-urba | Paris | Représente les salariés des agences ; CCN, classifications, congés, négociations |
|
||||||
|
| Branche Architecture | https://www.branche-architecture.fr | Branche professionnelle | National | Convention collective + fonds social | Paris | Site officiel de la branche — CCN IDCC 2332, fonds social solidarité, accords de branche |
|
||||||
|
| ANABF — Association Nationale des Architectes des Bâtiments de France | https://www.anabf.org | Association | National | Patrimoine + ABF + conseil | Paris | Regroupe les ABF ; missions de service public sur le patrimoine protégé et non protégé |
|
||||||
|
| FN CAUE — Fédération Nationale des CAUE | https://www.fncaue.com | Fédération | National | Architecture + urbanisme + environnement | Paris | Tête de réseau des 92 CAUE départementaux ; conseil gratuit aux particuliers et collectivités |
|
||||||
|
| CAUE Paris (75) | https://www.caue75.fr | CAUE | Régional | Conseil archi-urba-environnement | Paris | Conseil gratuit aux particuliers, collectivités et professionnels |
|
||||||
|
| CAUE Rhône Métropole (69) | https://caue69.fr | CAUE | Régional | Conseil archi-urba-environnement | Lyon | CAUE du Rhône — formations, conseil, veille |
|
||||||
|
| CAUE Gironde (33) | https://www.cauegironde.com | CAUE | Régional | Conseil archi-urba-environnement | Bordeaux | CAUE de Gironde — accompagnement projets, formations |
|
||||||
|
| CAUE Alsace | https://www.caue-alsace.com | CAUE | Régional | Conseil archi-urba-environnement | Strasbourg | CAUE Alsace — conseil et ressources architecture régionale |
|
||||||
|
| CAUE Seine-Maritime (76) | https://www.caue76.fr | CAUE | Régional | Conseil archi-urba-environnement | Rouen | CAUE Normandie-Seine-Maritime |
|
||||||
|
| Réseau des Maisons de l'Architecture | https://www.ma-lereseau.org | Réseau national | National | Culture architecturale + diffusion + résidences | Paris | Fédère 32 Maisons de l'architecture ; résidences, pédagogie, événements |
|
||||||
|
| Maison de l'Architecture Île-de-France | https://www.maisonarchitecture-idf.org | Maison de l'architecture | Régional | Culture architecturale + événements | Paris | Diffusion culture archi en IDF ; expositions, conférences, ressources |
|
||||||
|
| Maison de l'Architecture Occitanie-Pyrénées (MAOP) | https://maop.fr | Maison de l'architecture | Régional | Culture architecturale + publication Plan Libre | Toulouse | Édite Plan Libre, journal mensuel d'architecture depuis 2002 |
|
||||||
|
| Maison de l'Architecture Hauts-de-France | https://www.maisonarchitecture-hdf.fr | Maison de l'architecture | Régional | Culture architecturale + événements | Amiens | Diffusion et sensibilisation archi en Hauts-de-France |
|
||||||
|
| Maison de l'Architecture Centre-Val de Loire | https://ma-cvl.org | Maison de l'architecture | Régional | Culture architecturale + conseil | Tours | Accompagnement culture archi en Centre-Val de Loire |
|
||||||
|
| Maison de l'Architecture de Franche-Comté | https://www.maisondelarchi-fc.fr | Maison de l'architecture | Régional | Culture architecturale | Besançon | Diffusion et médiation architecture en Franche-Comté |
|
||||||
|
| MAF — Mutuelle des Architectes Français | https://www.maf.fr | Assurance mutuelle | National | Assurance RC professionnelle + protection juridique | Paris | Mutuelle fondée en 1931 ; RC obligatoire, protection juridique, fonds de solidarité |
|
||||||
|
| CIPAV — Caisse Interprofessionnelle de Prévoyance et Assurance Vieillesse | https://www.cipav.fr | Caisse retraite | National | Retraite + prévoyance professions libérales | Paris | Régime retraite des architectes libéraux et autres professions libérales |
|
||||||
|
| MIQCP — Mission Interministérielle pour la Qualité des Constructions Publiques | https://www.miqcp.gouv.fr | Mission d'État | National | Marchés publics + qualité construction + concours MOE | Paris | Conseil gratuit aux maîtres d'ouvrage publics, guides pratiques, concours archi |
|
||||||
|
| OPPIC — Opérateur du patrimoine et des projets immobiliers de la Culture | https://www.oppic.fr | Établissement public | National | Maîtrise d'ouvrage publique culturelle | Paris | MOA délégué pour constructions et rénovations culturelles ; AJAP organisateur |
|
||||||
|
| PUCA — Plan Urbanisme Construction Architecture | https://www.urbanisme-puca.gouv.fr | Programme d'État | National | Recherche + expérimentation urba-construction-archi | Paris | Recherche appliquée et expérimentation ; publications, séminaires, ressources libres |
|
||||||
|
| Cité de l'Architecture et du Patrimoine | https://www.citedelarchitecture.fr | Institution culturelle | National | Culture architecturale + formation continue + patrimoine | Paris | Musée + formation continue architectes agréés (Chaillot) ; bibliothèque, podcasts, expositions |
|
||||||
|
| Pavillon de l'Arsenal | https://www.pavillon-arsenal.com | Centre documentation | Régional | Documentation urbanism Paris + incubateur FAIRE | Paris | Centre info-documentation architecture et urbanisme parisien + programme FAIRE |
|
||||||
|
| ASF France — Architectes Sans Frontières | http://asffrance.org | Association humanitaire | National | Solidarité internationale + habitat + formation | Paris | Asso loi 1901 (1979) ; accompagnement technique projets humanitaires, sans salariés, 100% bénévoles |
|
||||||
|
| Architectes de l'Urgence | https://www.archi-urgent.com | Fondation humanitaire | National | Urgence post-catastrophe + sécurisation + reconstruction | Paris | Fondation reconnue d'utilité publique (2007) ; interventions post-désastre en France et monde |
|
||||||
|
| Architectes Solidaires | https://www.helloasso.com/associations/architectes-solidaires | Association | National | Bénévolat + rénovation + santé | France | Créée pendant le confinement 2020 ; rénovation gratuite d'espaces de repos dans hôpitaux publics |
|
||||||
|
| AMC Archi — Le Moniteur Architecture | https://www.amc-archi.com | Revue | National | Actualité architecture + projets + AJAP | Paris | Magazine de référence de la profession ; projets, actualité, AJAP, prix AMC |
|
||||||
|
| L'Architecture d'Aujourd'hui (AA) | https://www.larchitecturedaujourdhui.fr | Revue | National | Actualité architecture + essais + international | Paris | Plus ancienne revue d'architecture FR (1930) ; projets, débats, urbanisme, paysage |
|
||||||
|
| D'Architectures | https://www.darchitectures.com | Revue | National | Création architecturale + indépendant | Paris | Magazine indépendant de la création architecturale (88 bd de la Villette, 75019) |
|
||||||
|
| Archistorm | https://www.archistorm.com | Revue | National | Architecture + design + art contemporain | Paris | Revue bimestrielle fondée en 2004 ; architecture, design, art — 11 500 ex. |
|
||||||
|
| Archicréé (Architectures CREE) | https://archicree.com | Revue | National | Avant-gardes archi + design + tertiaire | Saint-Ouen | Revue depuis 1970 ; projets internationaux, tendances, esthétique européenne |
|
||||||
|
| Chroniques d'Architecture | https://chroniques-architecture.com | Média web | National | Actualité archi + podcast Parole d'archi + critiques | Paris | Site indépendant d'actualité architecturale ; presse, critiques, podcast, concours |
|
||||||
|
| Le Moniteur (lemoniteur.fr) | https://www.lemoniteur.fr | Presse professionnelle | National | Actualité BTP + marchés publics + architecture | Paris | Hebdomadaire de référence du BTP depuis 1903 ; construction, architecture, marchés |
|
||||||
|
| Batiactu | https://www.batiactu.com | Média web | National | Actualité BTP + architecture + immobilier | Paris | Premier portail BTP digital (2001) ; 2M pages vues/mois, architecture, tech, régl. |
|
||||||
|
| Batiweb | https://www.batiweb.com | Média web | National | Actualité BTP + produits + réglementation | Paris | Portail BTP professionnel quotidien ; produits, normes, emploi |
|
||||||
|
| Plan Libre | https://planlibre.eu | Revue régionale | Régional | Journal architecture Occitanie | Toulouse | Journal mensuel MA Occitanie-Pyrénées ; 200 numéros depuis 2002, hybride recherche-pratique |
|
||||||
|
| Team.archi — La French Connection | https://www.team.archi | Newsletter / réseau | National | Réseau + newsletter + emploi architectes | Paris | Newsletter 30 000+ architectes ; news agences, offres emploi, masterclass — Substack |
|
||||||
|
| Paroles d'archi (Chroniques d'Architecture) | https://chroniques-architecture.com/epoque/medias/podcasts/ | Podcast | National | Interviews architectes + projets + réflexion | Paris | Podcast de Chroniques d'architecture ; entretiens chefs d'agence, chercheurs |
|
||||||
|
| Podcast Pavillon de l'Arsenal | https://www.pavillon-arsenal.com | Podcast | Régional | Architecture + urbanisme Paris | Paris | Conférences et interviews architectes et urbanistes parisiens en audio |
|
||||||
|
| Dans la tête d'un archi (Xavier Le Pennec) | https://www.ausha.co/podcast/dans-la-tete-d-un-archi | Podcast | National | Exploration architecture + image | France | Podcast explorant le rôle de l'image et de l'imaginaire en architecture |
|
||||||
|
| Fondations (Solène Sillière) | https://open.spotify.com/show/foundationspodcast | Podcast | National | Responsabilité archi + ville + usagers | France | Podcast sur la responsabilité de l'architecture dans la fabrication des villes |
|
||||||
|
| GEPA — Groupe pour l'Éducation Permanente des Architectes | https://www.formation-architecte.com | Formation | National | Formation continue architectes (Paris) | Paris | Association loi 1901 depuis 1968 ; formations DPC obligatoires, agréé CNOA |
|
||||||
|
| MAJ Formation Continue | https://www.formation-architecte-maj.com | Formation | National | Formation continue architectes + juridique + gestion | Paris | Centre agréé Qualiopi (2020) ; formations juridique, gestion, technique, honoraires |
|
||||||
|
| Cité de l'Architecture — Formation continue | https://www.citedelarchitecture.fr/fr/article/formation-professionnelle-continue | Formation | National | Formation continue architectes du patrimoine | Paris | Formations agréées CNOA, spécialité patrimoine et réhabilitation, Chaillot |
|
||||||
|
| CFAA — Centre de Formation des Architectes Aquitains | https://www.cfaa-bordeaux.fr | Formation | Régional | Formation continue architectes Nouvelle-Aquitaine | Bordeaux | Membre GEPA ; formations DPC pour architectes du grand Sud-Ouest |
|
||||||
|
| REFC'A — Réseau Formation Continue Architectes | https://www.architectes.org/le-refca-91377 | Réseau | National | Réseau organismes formation agréés CNOA | Paris | Réseau des 13 organismes de formation continue habilités par l'Ordre |
|
||||||
|
| Grandes Ateliers de l'Isle d'Abeau | https://www.lesgrandsateliers.org | Formation + recherche | National | Innovation + matériaux + pédagogie archi | Isle d'Abeau | Lieu d'expérimentation grandeur nature ; formations, recherche, matériaux |
|
||||||
|
| DU Business Management Architecture (Dauphine) | https://executive-education.dauphine.psl.eu/formations/executive-master-diplome-universite/du-bm-archi | Formation | National | Gestion agence + management + stratégie | Paris | Diplôme universitaire CPF-éligible ; gestion financière, business plan, leadership agence |
|
||||||
|
| Archireport | https://www.archireport.com | Outil numérique | National | Suivi de chantier + rapports OPR | France | Logiciel suivi chantier 24 000 utilisateurs ; rapports, réserves, OPR, multi-plateformes |
|
||||||
|
| OOTI | https://www.ooti.co | Outil numérique | National | Gestion agence archi (ERP) | France | ERP 100% archi : projet, facturation, RH, CRM, notes de frais — 39€/mois/utilisateur |
|
||||||
|
| Archiliste | https://www.archiliste.fr | Annuaire / base données | National | Annuaire agences + projets + prescription | Paris | Annuaire 20 000+ agences, 3 500+ projets publiés ; newsletter, prescription BTP |
|
||||||
|
| ArchiWIZARD (Graitec) | https://graitec.com/fr/products/archiwizard/ | Outil numérique | National | RE2020 + simulation thermique + BIM | France | Logiciel agréé CSTB-DHUP pour RE2020 ; performance énergétique, ACV, intégration BIM |
|
||||||
|
| CYPETHERM RE2020 (CYPE) | http://cypetherm-re2020.cype.fr | Outil numérique | National | RE2020 + volet énergétique + Open BIM | France | Application Open BIM pour conformité RE2020 ; intégré BIMserver.center |
|
||||||
|
| Expert-Comptable Architectes (compta-architectes.com) | https://compta-architectes.com | Cabinet spécialisé | National | Comptabilité + fiscal + gestion agence archi | France | Cabinet exclusivement dédié aux agences d'archi depuis 20 ans ; constitution, transmission, honoraires |
|
||||||
|
| Expert-Comptable Architectes (expert-comptable-architectes.fr) | https://www.expert-comptable-architectes.fr | Cabinet spécialisé | National | Comptabilité + business plan + acquisition agence | France | Cabinet spécialisé archi ; création, développement, acquisition d'agences |
|
||||||
|
| Ordre des architectes — Questions juridiques | https://www.architectes.org/vos-questions-juridiques-112331 | Ressource juridique | National | Droit archi : contrats, litiges, PI, urba | Paris | FAQ juridique officielle CNOA ; droit privé, marchés publics, PI, déontologie |
|
||||||
|
| CROA IDF — Questions juridiques | https://www.architectes-idf.org/reponses-aux-questions-juridiques | Ressource juridique | Régional | Réponses juridiques gratuites pour architectes | Paris | Permanences juridiques CROA IDF ; contrats MOE, litiges, propriété intellectuelle |
|
||||||
|
| MAF — Protection juridique | https://www.maf.fr/assurance-professionnelle-architectes | Assurance juridique | National | RC pro + défense pénale + recours | Paris | Assurance RC obligatoire + protection juridique intégrée — 80% de la profession |
|
||||||
|
| Branche Architecture — Fonds social | https://www.branche-architecture.fr/action-sociale/les-prestations-sociales/ | Fonds solidarité | National | Aide sociale salariés archi : psycho, caregiver, parentalité | Paris | Fonds social branche archi (2,7 M€) ; accompagnement psy, aide caregiver, violences, prématurité |
|
||||||
|
| Souffrance et Travail | https://www.souffrance-et-travail.com | Association santé mentale | National | Soutien psy + juridique souffrance professionnelle | France | Réseau 200+ consultations locales ; info sur burn-out, harcèlement, droit du travail |
|
||||||
|
| France Burn-Out (asso-franceburnout.fr) | https://www.asso-franceburnout.fr | Association santé mentale | National | Burn-out : info + droits + psys + groupes de parole | France | Ressources burn-out, annuaire psychologues spécialisés, groupes de parole en France |
|
||||||
|
| Psycom — Santé Mentale Info | https://www.psycom.org | Ressource santé mentale | National | Info santé mentale + lignes écoute + annuaire | Paris | Portail info fiable et indépendant sur santé mentale ; annuaire consultations, guides par département |
|
||||||
|
| Réseau Burn Out (reseauburnout.org) | https://reseauburnout.org | Réseau santé | National/BE | Accompagnement pluridisciplinaire épuisement pro | France/Belgique | Réseau médecins, psys, coachs, juristes — attention : actif principalement Belgique/Luxembourg |
|
||||||
|
| Croix-Rouge — Écoute | https://www.croix-rouge.fr/soutien-psychosocial-par-telephone | Ligne d'écoute | National | Écoute psychosociale gratuite et anonyme | France | Ligne 0800 858 858 — gratuite, anonyme, 7j/7 ; soutien psychosocial de proximité |
|
||||||
|
| ASAfSAT | http://asafsat.asso.fr | Association santé mentale | National | Soutien psychologique salariés souffrance travail | France | Association loi 1901 ; accompagnement psy pour salariés en souffrance professionnelle |
|
||||||
|
| Archires — Revue du CROA Centre | https://ordrearchicentre.org/litiges-et-mediation/ | Ressource juridique | Régional | Litiges + médiation architectes Centre | Orléans | Service de médiation CROA Centre ; conciliation amiable obligatoire avant tribunal |
|
||||||
|
| ArchiBat RH | https://archibat.com | Recrutement | National | Recrutement archi + design + ingénierie + real estate | Paris | Cabinet de recrutement spécialisé architecture, design, ingénierie, immobilier |
|
||||||
|
| Archi-Jobs | https://www.archi-jobs.fr | Recrutement | National | Offres emploi architectes + intérieur + BIM | France | Portail emploi dédié architecture ; centaines d'offres archi, intérieur, urbanisme |
|
||||||
|
| Charette Service | https://www.charretteservice.fr | Recrutement | National | Recrutement archi + intérieur + projet + ingénierie | France | Plus de 40 ans de recrutement spécialisé archi ; toutes tailles d'agences |
|
||||||
|
| Preference Search | https://www.preferencesearch.fr | Recrutement | National | Recrutement cadres archi + intérieur + retail | France | Cabinet headhunting architecture et retail ; directeurs, architectes seniors |
|
||||||
|
| Annuaire CNOA (tableau des architectes) | https://annuaire.architectes.org | Annuaire officiel | National | Registre national architectes inscrits | Paris | Tableau officiel CNOA ; vérification inscription, compétences, adresse agences |
|
||||||
|
| UNAID — Union Nationale Architectes d'Intérieur Designers | https://unaid.fr | Syndicat | National | Archi intérieur + designers + label qualité | Paris | Syndicat affilié FFB depuis 1978 ; qualification, formation, défense archi intérieur |
|
||||||
|
| FFP — Fédération Française du Paysage | https://f-f-p.org | Fédération | National | Paysagistes concepteurs | France | 700+ paysagistes concepteurs ; défense du titre, formation, réseau 11 régions |
|
||||||
|
| Collectif Fil | http://collectif-fil.fr | Collectif | Régional | Recherche-action archi + urbanisme + habitants | Nantes | Collectif architects-urbanistes-chercheurs fondé 2013 ; transformation des territoires habités |
|
||||||
|
| Collectif Etc | https://www.collectifetc.com | Collectif | National | Expérimentation urbaine + DIY + participation | France | Collectif itinérant ; expérimentation architecturale et urbaine participative |
|
||||||
|
| FAIRE — Pavillon de l'Arsenal | https://www.pavillon-arsenal.com/fr/faire/ | Incubateur | Régional | Incubateur projets innovants archi + urbanism | Paris | Programme d'accélération et d'expérimentation lancé en 2017 ; financement et suivi prototypes |
|
||||||
|
| PUCA — Ressources en ligne | https://www.urbanisme-puca.gouv.fr/ressources-en-ligne-r9.html | Ressource recherche | National | Recherche urba-construction-archi ouverte | Paris | Rapports, séminaires, vidéos, podcasts libres sur urbanisme, construction, archi |
|
||||||
|
| Cité de l'Architecture — Podcasts | https://www.citedelarchitecture.fr/fr/article/podcasts | Podcast | National | Conférences + entretiens architectes | Paris | Série de podcasts de la Cité ; conférences, débats, paroles d'architectes du patrimoine |
|
||||||
|
| Archi-Urgent.com (Architectes de l'urgence) | https://www.archi-urgent.com | Fondation humanitaire | National | Urgence post-catastrophe + formation | Paris | Site officiel de la fondation ; appels aux dons, missions en cours, bénévolat |
|
||||||
|
| Construction21 France | https://www.construction21.org/france/ | Réseau / média | National | Bâtiment durable + technique + transition | France | Réseau professionnel bâtiment durable ; articles, retours d'expérience, veille RE2020 |
|
||||||
|
| Archiscopie | https://www.archiscopie.fr | Revue | National | Analyse archi + patrimoine + politique architecturale | Paris | Revue de l'ENSA et institutions ; analyses critiques, politique culturelle architecturale |
|
||||||
|
| Urbanisme (revue) | https://www.revue-urbanisme.fr | Revue | National | Urbanisme + ville + architecture urbaine | Paris | Revue de référence urbanisme depuis 1932 ; projets urbains, politiques, réflexions |
|
||||||
|
| NDA — Numéro d'Architecture | https://www.architecture-magazine-design.fr | Revue | National | Architecture + design | France | Magazine architecture et design — couverture internationale et projets FR |
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
### Entités douteuses / à vérifier
|
||||||
|
- `Réseau Burn Out (reseauburnout.org)` : initialement fondé en France, mais semble actif principalement en Belgique et Luxembourg — à vérifier avant d'inclure dans NAV
|
||||||
|
- URL CROA régionaux (Bretagne, AuRA, Nouvelle-Aquitaine, Grand Est, HDF) : URLs reconstruites à partir du pattern CNOA — à confirmer manuellement sur architectes.org/les-17-conseils-regionaux
|
||||||
|
- `Fondations` (podcast Solène Sillière) : URL Spotify approximative — à vérifier le flux RSS exact
|
||||||
|
- `Dans la tête d'un archi` : URL ausha approximative — à vérifier
|
||||||
|
|
||||||
|
### Redondances potentielles
|
||||||
|
- AMC et Le Moniteur : même groupe Infopro Digital, couvertures complémentaires (l'un est la revue mensuelle, l'autre est le portail web quotidien)
|
||||||
|
- Archiliste et Annuaire CNOA : deux annuaires complémentaires, l'un institutionnel, l'autre commercial-éditorial
|
||||||
|
- Plusieurs CROA et CAUE se recoupent fonctionnellement : garder tous car NAV est une carto multi-entrée
|
||||||
|
|
||||||
|
### Pistes d'approfondissement pour compléter à la main
|
||||||
|
1. **CROA manquants** : Occitanie, Centre-Val de Loire, Bourgogne-Franche-Comté, Corse, DOM-TOM — URLs à confirmer sur architectes.org
|
||||||
|
2. **Médias locaux / régionaux** : Rezo (revue CROA Normandie), Trait d'Union (CROA BFC), publications CROA spécifiques
|
||||||
|
3. **Ressources MAR / rénovation énergétique** : FAIRE, programme CEE, ADEME archi-spécifique, ANAH
|
||||||
|
4. **Outils BIM accessibles** : BIMsync, BIMcollab, plateformes GED projets archi FR
|
||||||
|
5. **Ressources honoraires / pricing** : pas de ressource dédiée identifiée — gap important à combler (blog, outil, guide UNSFA ?)
|
||||||
|
6. **Santé mentale spécifique archi** : aucun dispositif 100% dédié aux architectes trouvé — gap confirmé, à signaler dans NAV comme besoin non couvert
|
||||||
|
7. **Associations régionales type ARDEPA (Nantes), MA Rhône-Alpes** : non listées faute d'URLs confirmées
|
||||||
|
8. **Podcasts FR actifs** : vérifier si les podcasts listés sont encore actifs en 2026 (certains ont pu s'arrêter)
|
||||||
|
9. **Accélérateur Architecture de demain (France 2030)** : programme ADEME-BPI pour agences archi — à creuser
|
||||||
|
10. **Fonds MAF de solidarité** : accès aux architectes en difficulté financière — URL directe à ajouter
|
||||||
244
V2-cadrage/B-provider-ia-souverain.md
Normal file
244
V2-cadrage/B-provider-ia-souverain.md
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
# NAV — Choix provider IA souverain
|
||||||
|
|
||||||
|
Date : 2026-04-14
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## TL;DR
|
||||||
|
|
||||||
|
Recommandation : **C. Mistral direct** (mistral-small ou mistral-nemo) pour ces raisons : prix imbattable pour les volumes NAV (< 1 €/mois), souveraineté FR/EU native, zero data retention disponible sur API, OpenAI-compatible, zero setup.
|
||||||
|
|
||||||
|
Plan B : **A. Scaleway Generative APIs** si besoin d'un fournisseur FR 100% indépendant de Mistral (pas de relation directe), ou si les modèles Scaleway (Qwen, Llama, Mistral hébergés en FR) conviennent mieux pour un usage multi-modèle.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tableau comparatif
|
||||||
|
|
||||||
|
| Critère | Scaleway Gen. APIs | Synthetic.new | Mistral direct | Ollama self-host | Scaleway GPU |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| Souveraineté | FR (Scaleway SAS) | EU (localisation à confirmer) | FR (Paris) | 100% VPS Hetzner DE | FR (Scaleway SAS) |
|
||||||
|
| RGPD / rétention | Zéro rétention par défaut (2 sem. si incident) | Non documenté publiquement | 30 j par défaut, ZDR activable sur API | Zéro (local) | Zéro rétention (même politique que Gen. APIs) |
|
||||||
|
| Kimi 2.5 dispo | Non | Oui (hf:moonshotai/Kimi-K2.5 et K2-Thinking) | Non | Oui (quantisé, mais VPS trop petit) | Oui (déployable) |
|
||||||
|
| Meilleur modèle dispo | Qwen 3.5-397B, Mistral Small 3.2, Llama 3.3 70B | Kimi K2.5, Kimi K2-Thinking, 19+ modèles | Mistral Small 3.1, Mistral Medium 3, Mistral Large | Gemma3:4B, Phi-4 Mini, Qwen 3 4B (CPU-only) | Tout modèle open-weight |
|
||||||
|
| Prix/1M input | €0,15 | Forfait ~$30/mois tout compris | $0,02 (Nemo) / $0,20 (Small) | ~0 + infra | ~0 + GPU |
|
||||||
|
| Prix/1M output | €0,35 | inclus dans forfait | $0,04 (Nemo) / $0,60 (Small) | ~0 + infra | ~0 + GPU |
|
||||||
|
| Coût estimé/mois (usage NAV) | ~€0,30–€1,20 | $30 (forfait fixe) | ~$0,05–$0,50 | €7,99–€15,99 (upgrade VPS) | €50–€100+ si à la demande |
|
||||||
|
| Latence | Bonne (datacenters FR) | Bonne (EU) | Bonne (datacenters FR) | Très lente (CPU-only, 2–6 tok/s) | Excellente (GPU dédié) |
|
||||||
|
| Setup | OpenAI-compat, drop-in | OpenAI-compat | OpenAI-compat | Docker + model pull | VM + déploiement modèle |
|
||||||
|
| Plafonnement budget | Quota API configurable | Budget fixe par nature | Quota API + alertes | Infra-bound | Infra-bound + alertes billing |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Analyse par option
|
||||||
|
|
||||||
|
### A. Scaleway Generative APIs
|
||||||
|
|
||||||
|
**Forces :**
|
||||||
|
- Hébergement 100% France, filiale Iliad (groupe Xavier Niel)
|
||||||
|
- Zéro data retention par défaut — politique claire et documentée
|
||||||
|
- Drop-in OpenAI-compatible
|
||||||
|
- Batch API disponible : -50% sur le prix, sans rate limit (idéal pour le worker post-processing fiches)
|
||||||
|
- Modèles solides : Mistral Small 3.2, Qwen 3.5, Llama 3.3 70B
|
||||||
|
- Free tier 1M tokens/mois inclus
|
||||||
|
|
||||||
|
**Faiblesses :**
|
||||||
|
- Pas de Kimi 2.5 (si c'est un critère modèle)
|
||||||
|
- Pricing output (€0,35/1M) légèrement plus élevé que Mistral direct
|
||||||
|
- Dépend des modèles disponibles sur leur catalogue (moins de choix que Mistral direct)
|
||||||
|
|
||||||
|
**Coût estimé pour les 3 usages NAV :**
|
||||||
|
|
||||||
|
```
|
||||||
|
Usage 1 — Worker post-processing fiches
|
||||||
|
80 fiches/mois × (500 in + 500 out) = 80 000 tokens
|
||||||
|
Input : 0,08M × €0,15 = €0,01
|
||||||
|
Output : 0,08M × €0,35 = €0,03
|
||||||
|
Sous-total : ~€0,04/mois (ou gratuit dans le free tier)
|
||||||
|
|
||||||
|
Usage 2 — Chatbot recherche
|
||||||
|
150 req/mois × (2 000 in + 300 out) = 345 000 tokens
|
||||||
|
Input : 0,30M × €0,15 = €0,045
|
||||||
|
Output : 0,045M × €0,35 = €0,016
|
||||||
|
Sous-total : ~€0,06/mois
|
||||||
|
|
||||||
|
Usage 3 — Filtre éthique commentaires
|
||||||
|
150 req/mois × (200 in + 20 out) = 33 000 tokens
|
||||||
|
Sous-total : négligeable
|
||||||
|
|
||||||
|
Total Scaleway : ~€0,10–€0,20/mois (hors free tier)
|
||||||
|
Avec Batch API (-50%) sur Usage 1 : encore moins cher
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### B. Synthetic.new
|
||||||
|
|
||||||
|
**Forces :**
|
||||||
|
- Forfait fixe $30/mois : budgétairement prévisible
|
||||||
|
- Kimi K2.5 et K2-Thinking disponibles via API
|
||||||
|
- OpenAI-compatible (endpoint `api.synthetic.new`)
|
||||||
|
- Modèle "privacy-first" selon leur positionnement
|
||||||
|
|
||||||
|
**Faiblesses :**
|
||||||
|
- $30/mois est sur-dimensionné pour les volumes NAV actuels (on paierait 60–300× trop cher)
|
||||||
|
- Politique RGPD / data retention non documentée publiquement — point bloquant pour usage professionnel
|
||||||
|
- Localisation exacte des serveurs non confirmée (EU oui, mais pays ?)
|
||||||
|
- Dépendance à un petit acteur sans DPA public → risque de disparition ou changement tarifaire
|
||||||
|
- Le forfait usage-based existe mais les tarifs par token ne sont pas clairement publiés
|
||||||
|
|
||||||
|
**Coût estimé pour les 3 usages NAV :**
|
||||||
|
- $30/mois fixe quelle que soit la consommation
|
||||||
|
- Cohérent uniquement si usage intensif personnel/prod (> 500 req/jour)
|
||||||
|
- Pour NAV au stade actuel : sur-coût 60× vs Mistral
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### C. Mistral direct
|
||||||
|
|
||||||
|
**Forces :**
|
||||||
|
- Mistral = société française, siège à Paris, supervisée par CNIL
|
||||||
|
- ZDR (Zero Data Retention) activable directement sur l'API — le seul fournisseur à l'offrir explicitement avec documentation officielle
|
||||||
|
- OpenAI-compatible, drop-in, aucun setup
|
||||||
|
- Mistral Nemo : $0,02/$0,04 par 1M tokens → le moins cher du marché parmi les modèles sérieux
|
||||||
|
- Mistral Small 3.1 : $0,20/$0,60 — bon rapport qualité/prix pour le chatbot et le filtre éthique
|
||||||
|
- Data Processing Addendum disponible pour toutes les entreprises
|
||||||
|
|
||||||
|
**Faiblesses :**
|
||||||
|
- Pas de Kimi 2.5 (si besoin d'un modèle agentic spécifique)
|
||||||
|
- Mistral Large ($2/$6) ou Medium ($0,40/$2) deviennent chers si on monte en gamme
|
||||||
|
- Dépendance à un seul fournisseur pour tous les usages
|
||||||
|
|
||||||
|
**Coût estimé pour les 3 usages NAV :**
|
||||||
|
|
||||||
|
```
|
||||||
|
Stratégie recommandée :
|
||||||
|
Usage 1 (worker fiches) → mistral-nemo ($0,02/$0,04)
|
||||||
|
Usage 2 (chatbot) → mistral-small-3.1 ($0,20/$0,60)
|
||||||
|
Usage 3 (filtre) → mistral-nemo ($0,02/$0,04)
|
||||||
|
|
||||||
|
Usage 1 — 80 fiches/mois × 1 000 tokens = 80 000 tokens
|
||||||
|
Input : 0,04M × $0,02 = $0,001 | Output : 0,04M × $0,04 = $0,002
|
||||||
|
Sous-total : ~$0,003
|
||||||
|
|
||||||
|
Usage 2 — 150 req/mois × 2 300 tokens = 345 000 tokens
|
||||||
|
Input : 0,30M × $0,20 = $0,060 | Output : 0,045M × $0,60 = $0,027
|
||||||
|
Sous-total : ~$0,09
|
||||||
|
|
||||||
|
Usage 3 — 150 req/mois × 220 tokens = 33 000 tokens
|
||||||
|
Sous-total : ~$0,001
|
||||||
|
|
||||||
|
Total Mistral : ~$0,10–$0,15/mois (<< €1)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### D. Ollama self-hosted (VPS actuel)
|
||||||
|
|
||||||
|
**VPS actuel : 4 GB RAM, 2 vCPU (Hetzner CAX11 ou équivalent)**
|
||||||
|
- Insuffisant pour tout modèle 7B+ (minimum 8 GB RAM requis)
|
||||||
|
- Gemma3:4B ou Phi-4 Mini peuvent tourner en Q4_K_M (~3,5 GB), mais en CPU-only
|
||||||
|
- Performance CPU : 2–6 tokens/s → **inacceptable pour le chatbot** (temps de réponse 30–60s pour 300 tokens out)
|
||||||
|
- Pour le filtre éthique (20 tokens out) : marginalement utilisable, mais toujours lent
|
||||||
|
|
||||||
|
**Upgrade nécessaire :**
|
||||||
|
|
||||||
|
```
|
||||||
|
CAX21 (Hetzner ARM, 4 vCPU / 8 GB RAM) → €7,99/mois
|
||||||
|
Modèles possibles : Gemma3:4B Q8, Phi-4 Mini, Qwen3:4B
|
||||||
|
Performance CPU : ~8–15 tok/s (ARM Ampere)
|
||||||
|
Qualité : correcte pour le filtre éthique, limite pour le chatbot
|
||||||
|
|
||||||
|
CAX31 (Hetzner ARM, 8 vCPU / 16 GB RAM) → €15,99/mois
|
||||||
|
Modèles possibles : Gemma3:12B Q4, Mistral 7B Q8, Qwen3:8B
|
||||||
|
Performance CPU : ~5–10 tok/s sur 7–12B
|
||||||
|
Qualité : acceptable pour tous les usages NAV
|
||||||
|
Charge maintenance : Docker, model pull, mises à jour, monitoring
|
||||||
|
```
|
||||||
|
|
||||||
|
**Réalisme CPU-only :**
|
||||||
|
- Pas de GPU sur les VPS Hetzner standard → inférence CPU uniquement
|
||||||
|
- Mistral 7B ou Qwen 7B sur CPU = 3–6 tok/s = **non viable pour chatbot temps réel**
|
||||||
|
- Gemma3:4B sur CAX21 = seul compromis réaliste, mais qualité inférieure à Mistral Small
|
||||||
|
|
||||||
|
**Coût total :**
|
||||||
|
- CAX21 : €7,99/mois + temps de maintenance ~1h/mois
|
||||||
|
- CAX31 : €15,99/mois + temps de maintenance
|
||||||
|
- Dans les deux cas, prix 10–50× plus élevé que Mistral direct pour une qualité inférieure
|
||||||
|
|
||||||
|
**Conclusion Ollama :** pertinent uniquement si la contrainte de souveraineté est absolue (zéro tiers, données confidentielles très sensibles) ET si on accepte la latence. Pas recommandé pour le chatbot NAV.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### E. Scaleway GPU
|
||||||
|
|
||||||
|
**Contexte :**
|
||||||
|
- GPU instances Scaleway (L4, L40S, H100 SXM) en datacenter France
|
||||||
|
- H100 SXM : ~€3,50/h | A100 : ~€2,50/h | L4 : ~€0,50/h (estimation)
|
||||||
|
- Usage facturable à l'heure → adapté aux bursts, pas au serving continu
|
||||||
|
|
||||||
|
**Forces :**
|
||||||
|
- Souveraineté FR + RGPD = même niveau que Scaleway Generative APIs
|
||||||
|
- Liberté totale sur les modèles (déployer Kimi 2.5 quantisé, Mistral, Qwen...)
|
||||||
|
- Latence excellente (GPU dédié)
|
||||||
|
- Zéro dépendance à un provider d'inférence
|
||||||
|
|
||||||
|
**Faiblesses :**
|
||||||
|
- Coût fixe prohibitif : L4 à €0,50/h = €360/mois en continu → hors budget
|
||||||
|
- Pour usage sporadique (50–200 req/mois), il faut un orchestrateur qui allume/éteint le GPU
|
||||||
|
- Complexité d'orchestration (Kubernetes, Triton, ou script custom)
|
||||||
|
- Overkill pour les volumes NAV actuels
|
||||||
|
|
||||||
|
**Coût estimé :**
|
||||||
|
- En continu : €360–€2 500/mois selon GPU → hors budget 20€
|
||||||
|
- En spot/burst (5h/mois d'inférence L4) : ~€2,50/mois d'infra, mais complexité setup = 2–3j de travail
|
||||||
|
|
||||||
|
**Conclusion GPU Scaleway :** à considérer uniquement à très grande échelle (10 000+ req/mois) ou pour le déploiement d'un modèle custom non disponible ailleurs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Reco finale
|
||||||
|
|
||||||
|
**Mistral direct (option C)** est la reco sans hésitation pour NAV dans sa phase actuelle.
|
||||||
|
|
||||||
|
Le coût réel sera inférieur à $0,15/mois pour les 3 usages combinés — soit 100× sous le budget de 20€. La souveraineté est native (France, CNIL), le ZDR est activable en un paramètre API, et l'intégration est un copier-coller de clé API. La stratégie à deux modèles (Nemo pour le worker + filtre éthique, Small pour le chatbot) optimise le rapport qualité/prix.
|
||||||
|
|
||||||
|
Si dans 12–18 mois les volumes explosent (> 5 000 req/mois chatbot) ou si un modèle spécifique non disponible chez Mistral devient critique, Scaleway Generative APIs est le plan B naturel — même souveraineté, catalogue élargi, Batch API intégré.
|
||||||
|
|
||||||
|
Synthetic.new et Ollama self-hosted ne sont pas recommandés pour ce cas d'usage à ce stade.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Plafonnement budget
|
||||||
|
|
||||||
|
### Circuit breaker à 20€/mois
|
||||||
|
|
||||||
|
**Mistral direct** (recommandé) :
|
||||||
|
|
||||||
|
L'API Mistral expose des `usage` metrics dans chaque réponse. Options :
|
||||||
|
1. **Alerte billing Mistral** : configurer une notification à 10€ et un hard limit à 20€ dans le dashboard `console.mistral.ai` → Settings → Billing → Budget alerts
|
||||||
|
2. **Middleware applicatif** : compteur Redis cumulant les tokens, coupure si seuil atteint (patterns standard LangChain/LiteLLM)
|
||||||
|
3. **LiteLLM proxy** : mode budget_manager intégré, peut limiter par utilisateur et par total mensuel
|
||||||
|
|
||||||
|
**Scaleway Generative APIs** (plan B) :
|
||||||
|
- Interface billing avec quotas configurables par organisation
|
||||||
|
- Batch API comme soupape : si quota temps réel atteint, basculer en batch (-50% coût)
|
||||||
|
|
||||||
|
**Confort :** à $0,15/mois de consommation prévue, le budget de 20€ représente 133× la consommation estimée — le circuit breaker est une précaution, pas une urgence en phase de lancement.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sources consultées
|
||||||
|
|
||||||
|
- [Scaleway Model-as-a-service pricing](https://www.scaleway.com/en/pricing/model-as-a-service/)
|
||||||
|
- [Scaleway Generative APIs data privacy](https://www.scaleway.com/en/docs/generative-apis/reference-content/data-privacy/)
|
||||||
|
- [Scaleway supported models](https://www.scaleway.com/en/docs/generative-apis/reference-content/supported-models/)
|
||||||
|
- [Mistral AI pricing docs](https://docs.mistral.ai/deployment/ai-studio/pricing)
|
||||||
|
- [Mistral ZDR documentation](https://help.mistral.ai/en/articles/347612-can-i-activate-zero-data-retention-zdr)
|
||||||
|
- [Mistral data storage EU](https://help.mistral.ai/en/articles/347629-where-do-you-store-my-data-or-my-organization-s-data)
|
||||||
|
- [Synthetic.new pricing](https://synthetic.new/pricing)
|
||||||
|
- [Synthetic.new blog subscriptions](https://synthetic.new/blog/subscriptions)
|
||||||
|
- [Kimi K2.5 via Synthetic (TypingMind guide)](https://www.typingmind.com/guide/synthetic/hf-moonshotai-Kimi-K2-Thinking)
|
||||||
|
- [Hetzner CAX pricing](https://www.hetzner.com/cloud/cost-optimized)
|
||||||
|
- [Ollama VPS requirements](https://localllm.in/blog/ollama-vram-requirements-for-local-llms)
|
||||||
|
- [Best Ollama models 8GB RAM](https://localaimaster.com/blog/best-local-ai-models-8gb-ram)
|
||||||
|
- [EU LLM API comparison JuiceFactory 2026](https://juicefactory.ai/en/guides/eu-llm-api-comparison)
|
||||||
299
V2-cadrage/C-systeme-dons.md
Normal file
299
V2-cadrage/C-systeme-dons.md
Normal file
@@ -0,0 +1,299 @@
|
|||||||
|
# NAV — Choix système de dons
|
||||||
|
|
||||||
|
Date : 2026-04-14
|
||||||
|
|
||||||
|
## TL;DR
|
||||||
|
|
||||||
|
Recommandation principale : **Liberapay** (scénario collectif informel) ou **HelloAsso** (si ASO créée).
|
||||||
|
|
||||||
|
- **Sans ASO** : Liberapay est l'option la plus propre — 0% frais plateforme, open source FR/EU, aucun statut requis, dons récurrents, retrait SEPA gratuit. Fiscalement : dons liés à l'activité pro = CA imposable à déclarer à l'URSSAF.
|
||||||
|
- **Avec ASO** : HelloAsso domine — 0% frais, très connu en France, reçus fiscaux Cerfa possibles (si reconnaissance d'intérêt général), UX excellente.
|
||||||
|
|
||||||
|
Question à trancher par Jules : créer une ASO "Collectif NAV" en 15 min, ou rester collectif informel avec Liberapay ?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Point juridique / fiscal
|
||||||
|
|
||||||
|
### Statut actuel : auto-entrepreneur
|
||||||
|
|
||||||
|
Jules est auto-entrepreneur (micro-entreprise). Ce statut conditionne le traitement fiscal des dons reçus.
|
||||||
|
|
||||||
|
### Recevoir des dons en tant qu'auto-entrepreneur
|
||||||
|
|
||||||
|
**C'est légal, mais fiscalement contraint :**
|
||||||
|
|
||||||
|
- Les dons reçus en lien avec l'activité professionnelle (financer une plateforme que Jules porte via son AE) sont traités comme du **chiffre d'affaires**.
|
||||||
|
- Ils doivent être **déclarés à l'URSSAF** (mensuel ou trimestriel) et intégrés au CA imposable.
|
||||||
|
- Catégorie : BIC si l'AE relève déjà des BIC, BNC sinon.
|
||||||
|
- Conséquences : cotisations sociales + impôt sur le revenu (abattement micro-BIC/BNC standard).
|
||||||
|
- La TVA ne s'applique pas aux dons purs (sans contrepartie).
|
||||||
|
|
||||||
|
**Seuils 2026 pour information :**
|
||||||
|
- BNC/prestations de services : 83 600 €/an (très au-delà du volume NAV prévu)
|
||||||
|
|
||||||
|
**Dons non liés à l'activité pro :**
|
||||||
|
Si les dons proviennent de particuliers totalement extérieurs à l'activité (ex. famille, amis), ils relèvent des dons manuels entre particuliers — imposables uniquement si déclarés ou si montants dépassent les abattements familiaux. Pour 5-50 donateurs à 2-20 €/mois, **en pratique non significatif** mais l'ambiguïté reste réelle si la plateforme est publique.
|
||||||
|
|
||||||
|
**Risque principal :** Jules porte NAV sous son AE → les dons "pour NAV" sont clairement liés à son activité pro → déclaration URSSAF obligatoire. Cotisations sociales sur les montants (~40-60€/mois = impact faible, mais obligation existe).
|
||||||
|
|
||||||
|
### Alternative : ASO loi 1901 "Collectif NAV"
|
||||||
|
|
||||||
|
**Démarches :**
|
||||||
|
- Minimum 2 membres fondateurs (Jules + 1 autre)
|
||||||
|
- Rédaction statuts (modèle libre, ~1h)
|
||||||
|
- Déclaration en préfecture (en ligne sur service-public.fr)
|
||||||
|
- Publication au Journal Officiel (automatique, gratuit depuis 2020)
|
||||||
|
- Délai : 5 jours ouvrés pour la préfecture, 2-3 semaines au total
|
||||||
|
- Coût : 0 €
|
||||||
|
|
||||||
|
**Avantages ASO :**
|
||||||
|
- HelloAsso accessible (0% frais plateforme)
|
||||||
|
- Reçus fiscaux Cerfa possibles si rescrit fiscal (reconnaissance d'intérêt général — démarche supplémentaire, pertinente uniquement si donateurs personnes morales ou si montants significatifs)
|
||||||
|
- Pas d'ambiguïté : les dons vont à l'ASO, pas à Jules personnellement
|
||||||
|
- Séparation propre entre revenus Jules (AE) et fonds NAV (ASO)
|
||||||
|
- Exonération des impôts commerciaux (TVA, IS, CET) pour l'ASO si activité non lucrative
|
||||||
|
|
||||||
|
**Contraintes ASO :**
|
||||||
|
- AG annuelle obligatoire (formalité légère mais réelle)
|
||||||
|
- Compte bancaire ASO à ouvrir (banque ou Nickel, Boursobank Pro)
|
||||||
|
- Comptabilité simple (recettes/dépenses)
|
||||||
|
- Charge annuelle estimée : ~2-4h/an pour une petite structure
|
||||||
|
|
||||||
|
### Alternative 3 : Fonds de dotation
|
||||||
|
|
||||||
|
Trop lourd pour ce stade (capital de 15 000 €, commissaire aux comptes). Non pertinent.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tableau comparatif
|
||||||
|
|
||||||
|
| Option | Statut requis | Fees plateforme | Fees paiement | Récurrent | Reçu fiscal | Intégration | Souveraineté | UX donateur |
|
||||||
|
|--------|--------------|-----------------|---------------|-----------|-------------|-------------|--------------|-------------|
|
||||||
|
| **HelloAsso** | ASO déclarée | 0% (pourboire opt.) | 0% | Oui | Oui (si intérêt général) | Widget / lien / API | FR | ★★★★★ |
|
||||||
|
| **Liberapay** | Aucun | 0% | ~3% (Stripe) ou ~0,35€ SEPA | Oui (récurrent natif) | Non | Lien + widget | EU (FR, open source) | ★★★★ |
|
||||||
|
| **Stripe** | Compte pro / AE | 0% (propre intégration) | 1,5%+0,25€ (CB EU) / 0,35€ (SEPA) | Oui (Stripe Billing) | Non | API / Payment Links | US (SEPA OK) | ★★★★ |
|
||||||
|
| **Tipeee** | Aucun (particulier OK) | 8% TTC | Inclus | Oui | Non | Lien | FR | ★★★ |
|
||||||
|
| **Ko-fi** | Aucun | 0% (tips) / 5% (memberships) | ~2,9%+0,30$ (Stripe/PayPal) | Oui (Gold ou memberships) | Non | Lien + widget | UK | ★★★★ |
|
||||||
|
| **Buy Me a Coffee** | Aucun | 5% | Frais Stripe/PayPal | Oui | Non | Lien + widget | US | ★★★ |
|
||||||
|
| **Virement SEPA direct** | Aucun | 0% | 0% | Non (manuel) | Non | IBAN publié sur site | Total | ★★ |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Analyse par option
|
||||||
|
|
||||||
|
### A. HelloAsso
|
||||||
|
|
||||||
|
**Forces :**
|
||||||
|
- 0% de frais plateforme (modèle : pourboire optionnel des donateurs)
|
||||||
|
- Très connu en France, forte crédibilité associative
|
||||||
|
- Dons récurrents natifs
|
||||||
|
- Interface très soignée pour le donateur
|
||||||
|
- Reçus fiscaux Cerfa possibles (si rescrit fiscal d'intérêt général)
|
||||||
|
- Widget embed propre, API disponible
|
||||||
|
|
||||||
|
**Faiblesses :**
|
||||||
|
- Nécessite statut ASO déclarée (ou structure ESS reconnue)
|
||||||
|
- Pas accessible directement à Jules en tant qu'AE ou collectif informel
|
||||||
|
- Dépendance à une plateforme privée (même si FR)
|
||||||
|
|
||||||
|
**Pour NAV :** Meilleur choix si Jules crée l'ASO. La démarche prend 15 min et ouvre une option très propre, sans frais, avec crédibilité FR.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### B. Liberapay
|
||||||
|
|
||||||
|
**Forces :**
|
||||||
|
- 0% de frais plateforme (financé par dons à son propre compte)
|
||||||
|
- Aucun statut juridique requis — ouvert aux particuliers, collectifs, projets open source
|
||||||
|
- Dons récurrents natifs (hebdomadaire, mensuel)
|
||||||
|
- Retrait SEPA gratuit vers compte bancaire
|
||||||
|
- Open source, hébergé en EU, conformité RGPD native
|
||||||
|
- Parfaitement adapté aux projets tech/open source
|
||||||
|
- Interface simple, sobre
|
||||||
|
|
||||||
|
**Faiblesses :**
|
||||||
|
- Frais du processeur de paiement : ~3% Stripe ou ~0,35€ SEPA (en réalité faible sur petits montants)
|
||||||
|
- Moins connu du grand public FR que HelloAsso ou Tipeee
|
||||||
|
- UX moins "grand public" — plus adapté communautés tech
|
||||||
|
- Pas de reçu fiscal
|
||||||
|
- Transparence totale (le montant collecté est public par défaut)
|
||||||
|
|
||||||
|
**Pour NAV :** Option idéale pour démarrer sans ASO. Communauté de la plateforme bien alignée avec un projet collaboratif archi/tech. Frais réels très bas (2-5€ sur 100€ collectés).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### C. Stripe
|
||||||
|
|
||||||
|
**Forces :**
|
||||||
|
- Intégration native dans le site (Payment Links ou Checkout embed)
|
||||||
|
- SEPA Direct Debit à 0,35€/transaction — très économique sur petits montants récurrents
|
||||||
|
- Dons récurrents via Stripe Billing
|
||||||
|
- Complet techniquement (webhooks, dashboard transparent)
|
||||||
|
|
||||||
|
**Faiblesses :**
|
||||||
|
- Nécessite un compte Stripe (auto-entrepreneur ou entreprise — compte perso possible mais déconseillé pour activité récurrente)
|
||||||
|
- Pas de "page de dons" publique clé-en-main — développement requis
|
||||||
|
- Hébergement US (données RGPD : Stripe est conforme mais données hors EU)
|
||||||
|
- Pas de reçu fiscal
|
||||||
|
- Charge technique (maintenance intégration)
|
||||||
|
|
||||||
|
**Pour NAV :** Pertinent si Jules veut une intégration custom dans le site NAV avec contrôle total. Overkill pour un démarrage — à considérer à terme si volume justifie.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### D. Tipeee
|
||||||
|
|
||||||
|
**Forces :**
|
||||||
|
- Connu en France, confiance des donateurs
|
||||||
|
- Aucun statut requis (particulier OK)
|
||||||
|
- Dons récurrents natifs
|
||||||
|
|
||||||
|
**Faiblesses :**
|
||||||
|
- 8% TTC de commission — **le plus cher du comparatif** pour ce cas d'usage
|
||||||
|
- Positionné créateurs de contenu (YouTube, streaming) — peu adapté à un projet archi/tech collaboratif
|
||||||
|
- Esthétique "influenceur", pas forcément cohérente avec NAV
|
||||||
|
|
||||||
|
**Pour NAV :** À éviter. Les 8% de commission sont disproportionnés. Aucun avantage spécifique sur Liberapay ou Ko-fi.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### E. Ko-fi
|
||||||
|
|
||||||
|
**Forces :**
|
||||||
|
- 0% de frais sur les tips simples
|
||||||
|
- Aucun statut requis
|
||||||
|
- Interface propre, moderne
|
||||||
|
- Widget embed disponible
|
||||||
|
|
||||||
|
**Faiblesses :**
|
||||||
|
- Frais Stripe/PayPal sous-jacents (~2,9%+0,30$ par transaction — coûteux sur petits montants)
|
||||||
|
- Hébergement UK (post-Brexit — RGPD s'applique mais moins souverain)
|
||||||
|
- 5% plateforme sur memberships (sauf Ko-fi Gold à 6$/mois)
|
||||||
|
- Positionné créateurs (artistes, illustrateurs) — moins adapté projet tech
|
||||||
|
|
||||||
|
**Pour NAV :** Option correcte mais pas optimale. Ko-fi Gold (6$/mois) = 72€/an pour 0% fees — injustifié si volume < 500€/an collectés.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### F. Buy Me a Coffee
|
||||||
|
|
||||||
|
**Forces :**
|
||||||
|
- Simple, rapide à déployer
|
||||||
|
- 0% frais plateforme (frais Stripe/PayPal uniquement)
|
||||||
|
|
||||||
|
**Faiblesses :**
|
||||||
|
- Hébergement US
|
||||||
|
- RGPD moins clair
|
||||||
|
- Positionné très "créateur solo", peu sérieux pour un projet collectif
|
||||||
|
- Dollars par défaut
|
||||||
|
|
||||||
|
**Pour NAV :** Non adapté. Trop informel pour un outil professionnel d'architecture.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### G. GoFundMe / Leetchi
|
||||||
|
|
||||||
|
**Forces :**
|
||||||
|
- Campagnes ponctuelles bien connues
|
||||||
|
|
||||||
|
**Faiblesses :**
|
||||||
|
- Conçu pour campagnes one-shot, pas pour financement récurrent continu
|
||||||
|
- Fees : GoFundMe 0% + frais paiement ; Leetchi 5%
|
||||||
|
- Pas adapté au modèle "couvrir des frais d'infra en continu"
|
||||||
|
|
||||||
|
**Pour NAV :** Non pertinent.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### H. Virement SEPA direct
|
||||||
|
|
||||||
|
**Forces :**
|
||||||
|
- 0% fees total
|
||||||
|
- Souveraineté absolue
|
||||||
|
- Simplicité maximale (IBAN publié sur le site)
|
||||||
|
|
||||||
|
**Faiblesses :**
|
||||||
|
- Aucun don récurrent automatique — friction maximale pour le donateur
|
||||||
|
- Pas de tableau de bord, pas de transparence publique
|
||||||
|
- Pas de widget
|
||||||
|
- Conversion très faible (démarche active requise du donateur)
|
||||||
|
|
||||||
|
**Pour NAV :** Utile en complément (afficher l'IBAN pour ceux qui préfèrent), pas comme solution principale.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scénarios recommandés
|
||||||
|
|
||||||
|
### Scénario 1 : NAV reste collectif informel (Jules AE)
|
||||||
|
|
||||||
|
**Option recommandée : Liberapay**
|
||||||
|
|
||||||
|
Pourquoi :
|
||||||
|
- Aucune démarche administrative
|
||||||
|
- 0% frais plateforme, SEPA gratuit pour les retraits
|
||||||
|
- Techniquement aligné avec la nature open source / collaboratif de NAV
|
||||||
|
- Widget embeddable sur le site
|
||||||
|
- Transparence publique native (les donateurs voient le total collecté vs objectif)
|
||||||
|
- Open source, hébergement EU, RGPD natif
|
||||||
|
|
||||||
|
Fiscalité pour Jules :
|
||||||
|
- Les dons reçus via Liberapay pour financer NAV (plateforme portée par son AE) = **CA de l'AE**
|
||||||
|
- Déclaration URSSAF mensuelle/trimestrielle obligatoire
|
||||||
|
- Impôt : abattement micro-BNC (34%) puis imposition normale
|
||||||
|
- Sur 40-60€/mois collectés : cotisations ~15-20€, impôt selon TMI Jules
|
||||||
|
- Charge administrative réelle : ajouter 1 ligne dans la déclaration URSSAF
|
||||||
|
|
||||||
|
Virement SEPA direct en complément : publier l'IBAN NAV (compte bancaire dédié, même si perso) pour les virements ponctuels.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Scénario 2 : Jules crée une ASO "Collectif NAV"
|
||||||
|
|
||||||
|
**Option recommandée : HelloAsso**
|
||||||
|
|
||||||
|
Démarches (estimé ~2-3h) :
|
||||||
|
1. Rédiger statuts (modèle disponible sur service-public.fr)
|
||||||
|
2. Tenir une AG constitutive avec 2 membres minimum (Jules + co-fondateur)
|
||||||
|
3. Déclarer en préfecture via le portail en ligne (service-public.fr/associations)
|
||||||
|
4. Obtenir le récépissé (5 jours ouvrés)
|
||||||
|
5. Ouvrir un compte bancaire ASO (Nickel, Hello Bank Pro, ou banque classique)
|
||||||
|
6. Créer le compte HelloAsso
|
||||||
|
|
||||||
|
Avantages concrets :
|
||||||
|
- 0% frais plateforme (HelloAsso)
|
||||||
|
- Séparation propre : fonds NAV ≠ revenus Jules
|
||||||
|
- Transparence totale via HelloAsso (tableau de bord public optionnel)
|
||||||
|
- Reçus fiscaux Cerfa possibles si rescrit fiscal (démarche optionnelle, 2-3 mois)
|
||||||
|
- Crédibilité renforcée vis-à-vis des institutions (partenaires, mairies, etc.)
|
||||||
|
|
||||||
|
Charge annuelle :
|
||||||
|
- 1 AG par an (peut être rapide, par mail si statuts le permettent)
|
||||||
|
- Déclaration simple des comptes (recettes / dépenses)
|
||||||
|
- Aucun commissaire aux comptes requis sous 153 000 € de subventions publiques
|
||||||
|
- Durée : ~2-4h/an
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Reco finale
|
||||||
|
|
||||||
|
**Pour démarrer maintenant sans friction : Liberapay.** Zéro démarche, 0% plateforme, parfaitement adapté à NAV (projet open source / collaboratif tech), retrait SEPA gratuit vers le compte bancaire de Jules. Fiscalement, les dons s'intègrent au CA de l'AE — contrainte légère pour des montants de 40-60€/mois (quelques euros de cotisations). Ajouter un IBAN en complément pour les virements directs.
|
||||||
|
|
||||||
|
**À 6-12 mois, si NAV prend de l'ampleur : créer l'ASO et basculer sur HelloAsso.** La démarche est simple (2-3h), gratuite, et ouvre des options de crédibilité institutionnelle, de partenariats, et d'accès à des subventions. Le basculement Liberapay → HelloAsso est indolore (rediriger le widget).
|
||||||
|
|
||||||
|
**À éviter absolument :** Tipeee (8% de commission injustifiés), Buy Me a Coffee (US, peu sérieux), GoFundMe (one-shot uniquement).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Piège fiscal à connaître
|
||||||
|
|
||||||
|
Les dons reçus pour financer NAV ne sont **pas** des dons manuels entre particuliers (abattements familiaux) — ils sont des recettes professionnelles liées à l'activité de l'AE. Traitement = CA imposable + cotisations URSSAF. Ne pas les omettre de la déclaration, même si les montants sont faibles.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Questions ouvertes pour Jules
|
||||||
|
|
||||||
|
1. Y a-t-il déjà un co-fondateur potentiel pour l'ASO (si ce scénario est retenu) ?
|
||||||
|
2. Quel compte bancaire utiliser pour recevoir les fonds Liberapay — compte perso AE ou compte dédié NAV ?
|
||||||
|
3. Veux-tu afficher le total collecté publiquement sur le site (bandeau transparent) — Liberapay le permet nativement.
|
||||||
|
4. Si ASO créée : quelle dénomination — "Collectif NAV", "Association NAV", autre ?
|
||||||
1159
V2-cadrage/E-spec-frontend.md
Normal file
1159
V2-cadrage/E-spec-frontend.md
Normal file
File diff suppressed because it is too large
Load Diff
824
V2-cadrage/F-spec-pipe-collaboration.md
Normal file
824
V2-cadrage/F-spec-pipe-collaboration.md
Normal file
@@ -0,0 +1,824 @@
|
|||||||
|
# NAV V2 — Spec technique : pipe de collaboration
|
||||||
|
|
||||||
|
Date : 2026-04-14
|
||||||
|
Statut : draft v1 — à valider avant implémentation
|
||||||
|
Auteur : ATIS
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Schéma ASCII global de la pipe
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ SOUMISSION (mobile-first) │
|
||||||
|
│ │
|
||||||
|
│ [User remplit formulaire] │
|
||||||
|
│ ↓ │
|
||||||
|
│ POST /api/submit │
|
||||||
|
│ → validation Zod (nom requis, URL valide si fournie, email valide si fni) │
|
||||||
|
│ → rate check : ≤ 3 soumissions / IP / jour │
|
||||||
|
│ → NocoDB: INSERT row │
|
||||||
|
│ · moderation_status = pending │
|
||||||
|
│ · scrape_status = pending (si URL) | no_link (si pas d'URL) │
|
||||||
|
│ · ai_processed = false │
|
||||||
|
│ → HTTP 201 + message "Merci, fiche en cours de traitement" │
|
||||||
|
└─────────────────────────────────────────────────────────────────────────────┘
|
||||||
|
↓
|
||||||
|
(async — NE PAS bloquer la requête HTTP)
|
||||||
|
↓
|
||||||
|
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ WORKER CRON (toutes les 5 min) │
|
||||||
|
│ │
|
||||||
|
│ 1. Fetch NocoDB: rows WHERE ai_processed=false AND moderation_status=pending│
|
||||||
|
│ 2. Pour chaque row : │
|
||||||
|
│ │
|
||||||
|
│ a. [Si scrape_status=pending ET URL présente] │
|
||||||
|
│ → crawl4ai scrape (timeout 3 min) │
|
||||||
|
│ → Si succès : scrape_status = scraped, contenu stocké │
|
||||||
|
│ → Si échec : scrape_status = failed → passer à l'étape d │
|
||||||
|
│ │
|
||||||
|
│ b. [Budget check avant appel IA] │
|
||||||
|
│ → Lire stats_usage: cumul coût_eur mois courant │
|
||||||
|
│ → Si ≥ 20€ : row laissée en pending, worker s'arrête │
|
||||||
|
│ │
|
||||||
|
│ c. [Appel Mistral Nemo — enrichissement] │
|
||||||
|
│ → Prompt enrichissement (voir §3) │
|
||||||
|
│ → Retry 2× si erreur réseau ou JSON mal formé │
|
||||||
|
│ → Si 3 échecs : ai_processed=false, moderation_status=ai_error │
|
||||||
|
│ → Si succès : parse JSON, update NocoDB fields │
|
||||||
|
│ │
|
||||||
|
│ d. [Update NocoDB] │
|
||||||
|
│ → ai_processed = true │
|
||||||
|
│ → moderation_status = ai_processed │
|
||||||
|
│ → description_enrichie, points_cles, tags_fonction, etc. │
|
||||||
|
│ → ai_raw_output (debug) │
|
||||||
|
│ │
|
||||||
|
│ e. [Log tokens] │
|
||||||
|
│ → INSERT stats_usage (tokens_in, tokens_out, coût_eur, model, ts) │
|
||||||
|
│ │
|
||||||
|
│ 3. Si N nouvelles fiches ai_processed : email Jules "N fiches à modérer" │
|
||||||
|
└─────────────────────────────────────────────────────────────────────────────┘
|
||||||
|
↓
|
||||||
|
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ MODÉRATION MANUELLE (Jules, review hebdo) │
|
||||||
|
│ │
|
||||||
|
│ Interface : NocoDB UI (ou page admin custom) │
|
||||||
|
│ Jules filtre sur moderation_status = ai_processed │
|
||||||
|
│ Pour chaque fiche : Approve → published | Reject → rejected │
|
||||||
|
│ moderated_at = NOW(), moderator_note optionnel │
|
||||||
|
└─────────────────────────────────────────────────────────────────────────────┘
|
||||||
|
↓
|
||||||
|
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ FRONT │
|
||||||
|
│ │
|
||||||
|
│ GET /api/orgs → liste fiches WHERE moderation_status=published │
|
||||||
|
│ Carte Leaflet/MapLibre + filtres taxonomie │
|
||||||
|
└─────────────────────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
PIPE COMMENTAIRE (parallèle) :
|
||||||
|
|
||||||
|
[User soumet commentaire]
|
||||||
|
→ POST /api/comment
|
||||||
|
→ rate check : ≤ 5 / IP / jour
|
||||||
|
→ Budget check
|
||||||
|
→ Mistral Nemo : filtre éthique (prompt §4)
|
||||||
|
→ Si safe=true : INSERT comment, published=true
|
||||||
|
→ Si safe=false : INSERT comment, published=false, flag pending review Jules
|
||||||
|
→ Log usage
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Schéma NocoDB — champs complets table organisations
|
||||||
|
|
||||||
|
Table : `m08t7g5v4wch6wb` (base `pipilvsi7dibo80`)
|
||||||
|
|
||||||
|
```
|
||||||
|
Champ Type Contraintes / Notes
|
||||||
|
─────────────────────────────────────────────────────────────────────────────
|
||||||
|
id auto-int PK, auto-increment
|
||||||
|
nom text required, max 200 chars
|
||||||
|
url text nullable, validé comme URL si présent
|
||||||
|
description_user longtext saisie brute du soumetteur, nullable
|
||||||
|
description_enrichie longtext générée par IA, nullable jusqu'au traitement
|
||||||
|
points_cles longtext JSON array stringifié ["bullet1","bullet2",…]
|
||||||
|
nullable jusqu'au traitement IA
|
||||||
|
echelle select values: National | Régional | Départemental | Local
|
||||||
|
nullable (IA propose, Jules valide)
|
||||||
|
territoire select values: Métropole | Guadeloupe | Martinique |
|
||||||
|
Guyane | Réunion | Mayotte
|
||||||
|
nullable
|
||||||
|
tags_fonction multiselect values: Juridique | Technique | Économique |
|
||||||
|
Administratif | Chantier | Comptabilité |
|
||||||
|
Prospection | RH | Santé mentale
|
||||||
|
max 5 valeurs (MVP : pas d'ordre priorité,
|
||||||
|
ordre = ordre de sélection)
|
||||||
|
localisation_ville text nullable, extraite par IA ou saisie user
|
||||||
|
latitude decimal(9,6) nullable, géocodage post-modération (hors MVP)
|
||||||
|
longitude decimal(9,6) nullable, idem
|
||||||
|
scrape_status select values: pending | scraped | failed | no_link
|
||||||
|
default: pending si URL, no_link sinon
|
||||||
|
moderation_status select values: pending | ai_processed | ai_error |
|
||||||
|
approved | rejected
|
||||||
|
default: pending
|
||||||
|
ai_processed checkbox false par défaut, true après traitement worker
|
||||||
|
submitted_by_email text nullable, pas de validation format strict
|
||||||
|
(tolérance typos côté UX)
|
||||||
|
submitted_at datetime auto NOW() à l'INSERT
|
||||||
|
moderated_at datetime nullable, rempli par Jules lors de la modération
|
||||||
|
moderator_note text nullable, note interne Jules
|
||||||
|
ai_raw_output longtext JSON brut retourné par Mistral, pour debug
|
||||||
|
nullable, peut être purgé périodiquement
|
||||||
|
scrape_content longtext contenu markdown extrait par crawl4ai
|
||||||
|
nullable, utilisé comme input IA puis archivé
|
||||||
|
─────────────────────────────────────────────────────────────────────────────
|
||||||
|
```
|
||||||
|
|
||||||
|
### Table avis : `m4hub7cdutgec47`
|
||||||
|
|
||||||
|
```
|
||||||
|
Champ Type Notes
|
||||||
|
─────────────────────────────────────────────────────────────────────────────
|
||||||
|
id auto-int PK
|
||||||
|
orga_id int FK → table organisations.id
|
||||||
|
contenu longtext required
|
||||||
|
auteur_pseudo text nullable
|
||||||
|
auteur_email text nullable (non affiché publiquement)
|
||||||
|
published checkbox true si filtre éthique OK, false si pending
|
||||||
|
safe_check select values: safe | flagged | pending
|
||||||
|
default: pending
|
||||||
|
safe_reason text nullable, raison du flag IA
|
||||||
|
submitted_at datetime auto NOW()
|
||||||
|
moderated_at datetime nullable
|
||||||
|
─────────────────────────────────────────────────────────────────────────────
|
||||||
|
```
|
||||||
|
|
||||||
|
### Table stats_usage (nouvelle à créer)
|
||||||
|
|
||||||
|
```
|
||||||
|
Champ Type Notes
|
||||||
|
─────────────────────────────────────────────────────────────────────────────
|
||||||
|
id auto-int PK
|
||||||
|
model text ex. "mistral-nemo-latest"
|
||||||
|
endpoint text ex. "enrichissement" | "filtre-ethique" | "chatbot"
|
||||||
|
tokens_in int
|
||||||
|
tokens_out int
|
||||||
|
cout_eur decimal(8,6) calculé : (in × prix_in + out × prix_out) / 1_000_000
|
||||||
|
timestamp datetime auto NOW()
|
||||||
|
orga_id int nullable (lié à la fiche si enrichissement)
|
||||||
|
─────────────────────────────────────────────────────────────────────────────
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Prompt Mistral Nemo — enrichissement fiche
|
||||||
|
|
||||||
|
### Paramètres d'appel
|
||||||
|
|
||||||
|
```
|
||||||
|
model: "open-mistral-nemo"
|
||||||
|
temperature: 0.2
|
||||||
|
max_tokens: 800
|
||||||
|
response_format: { type: "json_object" }
|
||||||
|
```
|
||||||
|
|
||||||
|
### System prompt (copier-coller tel quel)
|
||||||
|
|
||||||
|
```
|
||||||
|
Tu es un assistant spécialisé dans l'écosystème professionnel de l'architecture en France. Tu reçois des informations sur une organisation ou ressource liée au secteur de l'architecture, et tu dois les enrichir pour alimenter une cartographie collaborative.
|
||||||
|
|
||||||
|
RÈGLES ABSOLUES :
|
||||||
|
1. Tu ne dois JAMAIS inventer d'informations non présentes dans les sources fournies.
|
||||||
|
2. Si une information est absente ou incertaine, retourne `null` pour ce champ.
|
||||||
|
3. Tu dois retourner UNIQUEMENT un objet JSON valide, sans texte avant ou après.
|
||||||
|
4. La description_enrichie doit être neutre, factuelle, en français, sans jugement de valeur.
|
||||||
|
5. Les points_cles sont des phrases courtes (max 12 mots chacune), actionnables pour un architecte.
|
||||||
|
6. Pour les tags_fonction, ne propose que des valeurs parmi la liste autorisée.
|
||||||
|
|
||||||
|
TAXONOMIE AUTORISÉE :
|
||||||
|
- Échelle (une seule valeur) : "National" | "Régional" | "Départemental" | "Local"
|
||||||
|
- Territoire (une seule valeur) : "Métropole" | "Guadeloupe" | "Martinique" | "Guyane" | "Réunion" | "Mayotte" | null
|
||||||
|
- Tags fonction (1 à 5 valeurs) : "Juridique" | "Technique" | "Économique" | "Administratif" | "Chantier" | "Comptabilité" | "Prospection" | "RH" | "Santé mentale"
|
||||||
|
|
||||||
|
FORMAT DE SORTIE JSON :
|
||||||
|
{
|
||||||
|
"description_enrichie": "string (max 300 chars, français, neutre, factuel)",
|
||||||
|
"points_cles": ["string", "string", "string"],
|
||||||
|
"tags_fonction": ["Valeur1", "Valeur2"],
|
||||||
|
"echelle": "National" | "Régional" | "Départemental" | "Local" | null,
|
||||||
|
"territoire": "Métropole" | ... | null,
|
||||||
|
"localisation_ville": "string" | null,
|
||||||
|
"confiance": "haute" | "moyenne" | "faible"
|
||||||
|
}
|
||||||
|
|
||||||
|
Le champ "confiance" reflète ta certitude globale sur l'enrichissement :
|
||||||
|
- "haute" : URL scrapée avec contenu riche, informations claires
|
||||||
|
- "moyenne" : URL scrapée mais contenu partiel, ou description_user seule suffisante
|
||||||
|
- "faible" : URL non disponible et description_user vague, inférences importantes
|
||||||
|
```
|
||||||
|
|
||||||
|
### User prompt (template — substituer les variables)
|
||||||
|
|
||||||
|
```
|
||||||
|
ORGANISATION À ENRICHIR :
|
||||||
|
|
||||||
|
Nom : {{nom}}
|
||||||
|
URL : {{url_ou_"non fournie"}}
|
||||||
|
Description soumise par l'utilisateur : {{description_user_ou_"non fournie"}}
|
||||||
|
|
||||||
|
CONTENU DU SITE WEB (extrait par scraping) :
|
||||||
|
{{scrape_content_ou_"Site non accessible ou URL non fournie."}}
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Enrichis cette fiche selon les règles du system prompt. Retourne uniquement le JSON.
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Prompt Mistral Nemo — filtre éthique commentaires
|
||||||
|
|
||||||
|
### Paramètres d'appel
|
||||||
|
|
||||||
|
```
|
||||||
|
model: "open-mistral-nemo"
|
||||||
|
temperature: 0.0
|
||||||
|
max_tokens: 100
|
||||||
|
response_format: { type: "json_object" }
|
||||||
|
```
|
||||||
|
|
||||||
|
### System prompt (copier-coller tel quel)
|
||||||
|
|
||||||
|
```
|
||||||
|
Tu es un modèle de modération de contenu. Tu analyses des commentaires soumis sur une plateforme professionnelle française dédiée aux architectes. Tu dois détecter les contenus problématiques.
|
||||||
|
|
||||||
|
CONTENUS À SIGNALER (safe=false) :
|
||||||
|
- Propos racistes, antisémites, islamophobes, xénophobes
|
||||||
|
- Propos sexistes, misogynes, homophobes, transphobes, LGBTQIA-phobes
|
||||||
|
- Propos validistes (mépris des personnes handicapées)
|
||||||
|
- Diffamation personnelle nominative (accusations graves sans fondement contre une personne identifiable)
|
||||||
|
- Spam publicitaire explicite (lien commercial non sollicité + texte promotionnel)
|
||||||
|
- Harcèlement ciblé, menaces explicites
|
||||||
|
|
||||||
|
CONTENUS À AUTORISER (safe=true) :
|
||||||
|
- Critiques d'organisations ou de services (même sévères), sans attaque personnelle
|
||||||
|
- Partages d'expériences professionnelles négatives factuelles
|
||||||
|
- Désaccords et débats professionnels
|
||||||
|
- Signalement d'erreurs ou d'informations inexactes
|
||||||
|
|
||||||
|
RÈGLES :
|
||||||
|
1. En cas de doute, favorise safe=true (la liberté d'expression professionnelle prime).
|
||||||
|
2. Le champ "category" est obligatoire si safe=false.
|
||||||
|
3. Le champ "reason" est une phrase courte en français (max 20 mots).
|
||||||
|
4. Retourne UNIQUEMENT un objet JSON valide.
|
||||||
|
|
||||||
|
FORMAT DE SORTIE :
|
||||||
|
{
|
||||||
|
"safe": true | false,
|
||||||
|
"category": "racisme" | "sexisme" | "homophobie" | "antisémitisme" | "lgbtqia-phobie" | "validisme" | "diffamation" | "spam" | "harcelement" | null,
|
||||||
|
"reason": "string (max 20 mots)" | null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### User prompt (template)
|
||||||
|
|
||||||
|
```
|
||||||
|
COMMENTAIRE À ANALYSER :
|
||||||
|
|
||||||
|
"{{contenu_commentaire}}"
|
||||||
|
|
||||||
|
Analyse ce commentaire et retourne le JSON de modération.
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Mapping normalisation tags
|
||||||
|
|
||||||
|
Le worker utilise ce mapping pour corriger et compléter les tags suggérés par l'IA. L'IA peut nommer les tags librement dans son raisonnement interne, mais le worker applique cette normalisation avant d'écrire en base.
|
||||||
|
|
||||||
|
```
|
||||||
|
Entrée (variantes fréquentes) → Tag normalisé
|
||||||
|
──────────────────────────────────────────────────────────────────────────────
|
||||||
|
JURIDIQUE
|
||||||
|
"droit du travail", "droit de l'urbanisme", → Juridique
|
||||||
|
"litiges", "contrats MOE", "PI",
|
||||||
|
"propriété intellectuelle", "déontologie",
|
||||||
|
"marchés publics droit", "CCAG",
|
||||||
|
"responsabilité décennale", "médiation"
|
||||||
|
|
||||||
|
TECHNIQUE
|
||||||
|
"RE2020", "réglementation thermique", → Technique
|
||||||
|
"structure", "BIM", "DTU",
|
||||||
|
"normes acoustiques", "performance énergétique",
|
||||||
|
"matériaux", "simulation thermique",
|
||||||
|
"OPR", "réserves chantier", "ACV",
|
||||||
|
"accessibilité PMR", "eurodes"
|
||||||
|
|
||||||
|
ÉCONOMIQUE
|
||||||
|
"prix", "tarifs", "honoraires", "devis", → Économique
|
||||||
|
"ROI", "financement", "subventions",
|
||||||
|
"CEE", "MaPrimeRénov", "ANAH",
|
||||||
|
"business plan agence", "pricing MOE"
|
||||||
|
|
||||||
|
ADMINISTRATIF
|
||||||
|
"permis de construire", "PC", "DP", → Administratif
|
||||||
|
"déclaration préalable", "PLU", "PLUi",
|
||||||
|
"urbanisme réglementaire", "ERP",
|
||||||
|
"autorisation travaux", "ABF", "patrimoine",
|
||||||
|
"marchés publics procédure", "CCTP", "DPGF",
|
||||||
|
"concours architecture"
|
||||||
|
|
||||||
|
CHANTIER
|
||||||
|
"coordination chantier", "DET", → Chantier
|
||||||
|
"suivi travaux", "OPR", "levée réserves",
|
||||||
|
"coordo SPS", "sécurité chantier",
|
||||||
|
"planning chantier", "entreprises",
|
||||||
|
"sous-traitance", "réception travaux"
|
||||||
|
|
||||||
|
COMPTABILITÉ
|
||||||
|
"comptabilité agence", "fiscal", → Comptabilité
|
||||||
|
"TVA", "micro-BNC", "BNC", "BIC",
|
||||||
|
"expert-comptable", "bilan", "trésorerie",
|
||||||
|
"transmission agence", "création agence"
|
||||||
|
|
||||||
|
PROSPECTION
|
||||||
|
"développement commercial", "clients", → Prospection
|
||||||
|
"réseau", "candidatures marchés",
|
||||||
|
"réponse consultation", "acquisition",
|
||||||
|
"marketing agence", "notoriété"
|
||||||
|
|
||||||
|
RH
|
||||||
|
"recrutement", "emploi", "salaires", → RH
|
||||||
|
"CCN architecture", "convention collective",
|
||||||
|
"IDCC 2332", "temps de travail",
|
||||||
|
"formation continue", "DPC", "CPF",
|
||||||
|
"management équipe"
|
||||||
|
|
||||||
|
SANTÉ MENTALE
|
||||||
|
"burn-out", "épuisement professionnel", → Santé mentale
|
||||||
|
"souffrance au travail", "bien-être",
|
||||||
|
"harcèlement moral", "stress",
|
||||||
|
"soutien psychologique", "équilibre vie pro/perso"
|
||||||
|
──────────────────────────────────────────────────────────────────────────────
|
||||||
|
```
|
||||||
|
|
||||||
|
### Logique de normalisation dans le worker
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Pseudo-code — pas du code final
|
||||||
|
function normalizeTag(raw) {
|
||||||
|
const tag = raw.toLowerCase().trim()
|
||||||
|
for (const [pattern, normalized] of TAG_MAP) {
|
||||||
|
if (tag.includes(pattern)) return normalized
|
||||||
|
}
|
||||||
|
return null // tag non reconnu : ignoré, pas inséré
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Si l'IA retourne un tag non reconnu, il est ignoré silencieusement (pas d'erreur). Si zéro tags valides après normalisation : champ laissé vide, fiche toujours traitée.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Circuit breaker budget 20€/mois
|
||||||
|
|
||||||
|
### Logique de calcul des coûts
|
||||||
|
|
||||||
|
```
|
||||||
|
Mistral Nemo :
|
||||||
|
cout = (tokens_in / 1_000_000 × 0.02) + (tokens_out / 1_000_000 × 0.04)
|
||||||
|
(prix en USD — convertir en EUR au taux du jour ou fixer à 0.93)
|
||||||
|
|
||||||
|
Mistral Small :
|
||||||
|
cout = (tokens_in / 1_000_000 × 0.20) + (tokens_out / 1_000_000 × 0.60)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Paliers de déclenchement
|
||||||
|
|
||||||
|
| Seuil | Action automatique |
|
||||||
|
|----------|--------------------|
|
||||||
|
| 15€ | Email Jules : "Budget IA : 15€ atteints ce mois. Estimation fin de mois : X€." |
|
||||||
|
| 18€ | Banner site : "La curation IA approche sa limite mensuelle. Soutenir NAV → [lien dons]" |
|
||||||
|
| 19€ | Email Jules : "Budget IA : 19€. Hard stop imminent." |
|
||||||
|
| 20€ | Hard stop : worker refuse tout nouvel appel IA. Banner site : "IA en pause ce mois. Reprise le 1er [mois+1]." |
|
||||||
|
|
||||||
|
### Implémentation
|
||||||
|
|
||||||
|
```
|
||||||
|
Avant chaque appel IA dans le worker :
|
||||||
|
|
||||||
|
1. SELECT SUM(cout_eur) FROM stats_usage
|
||||||
|
WHERE timestamp >= DATE_TRUNC('month', NOW())
|
||||||
|
|
||||||
|
2. Si total >= 20.00 → throw new Error('BUDGET_EXCEEDED'), skip la fiche, continuer le cron
|
||||||
|
|
||||||
|
3. Si total >= 18.00 → set flag budget_warning=true en config (env ou NocoDB config row)
|
||||||
|
→ front affiche banner "pause imminente"
|
||||||
|
|
||||||
|
4. Emails automatiques à 15€ et 19€ :
|
||||||
|
→ Vérifier si email déjà envoyé ce mois (flag en NocoDB config ou fichier lock)
|
||||||
|
→ Si non : sendmail Jules, set flag
|
||||||
|
```
|
||||||
|
|
||||||
|
### Gestion mid-pipeline
|
||||||
|
|
||||||
|
Si le budget est atteint pendant le traitement d'un batch :
|
||||||
|
- La fiche en cours de traitement **termine** son cycle complet (pas d'état incohérent)
|
||||||
|
- Les fiches suivantes dans le batch sont laissées en `pending` sans appel IA
|
||||||
|
- Le cron repassera au prochain cycle, vérifiera le budget, et restera bloqué jusqu'au 1er du mois suivant
|
||||||
|
|
||||||
|
### Reset mensuel
|
||||||
|
|
||||||
|
Cron mensuel (1er du mois à 00h05) :
|
||||||
|
- Clear les flags budget (`budget_15_sent`, `budget_19_sent`, `hard_stop_active`)
|
||||||
|
- Worker reprend normalement
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Endpoints API à créer
|
||||||
|
|
||||||
|
```
|
||||||
|
Méthode Endpoint Description
|
||||||
|
──────────────────────────────────────────────────────────────────────────────
|
||||||
|
POST /api/submit Soumission nouvelle fiche
|
||||||
|
Body: { nom, url?, description_user?,
|
||||||
|
echelle?, territoire?, tags_fonction?,
|
||||||
|
localisation_ville?, submitted_by_email? }
|
||||||
|
Rate limit: 3 req/IP/jour
|
||||||
|
Réponse: 201 { message, id } | 400 (validation) |
|
||||||
|
429 (rate limit)
|
||||||
|
|
||||||
|
POST /api/comment Soumission commentaire
|
||||||
|
Body: { orga_id, contenu, auteur_pseudo?,
|
||||||
|
auteur_email? }
|
||||||
|
Rate limit: 5 req/IP/jour
|
||||||
|
Filtre éthique IA synchrone (Mistral Nemo)
|
||||||
|
Réponse: 201 { published, message } | 400 | 429
|
||||||
|
|
||||||
|
GET /api/orgs Liste des fiches publiées pour la carte
|
||||||
|
Query params: ?echelle=&territoire=&fonction=
|
||||||
|
Réponse: 200 [{ id, nom, url, description_enrichie,
|
||||||
|
points_cles, echelle, territoire,
|
||||||
|
tags_fonction, localisation_ville,
|
||||||
|
latitude, longitude }]
|
||||||
|
Pas d'auth, public, cacheable 5 min
|
||||||
|
|
||||||
|
GET /api/search Recherche texte classique (fallback chatbot)
|
||||||
|
Query: ?q=string&limit=10
|
||||||
|
Recherche sur: nom, description_enrichie, tags_fonction
|
||||||
|
SQL ILIKE ou NocoDB search API
|
||||||
|
Réponse: 200 [{ id, nom, url, echelle, tags_fonction }]
|
||||||
|
|
||||||
|
POST /api/chatbot Requête chatbot Mistral Small
|
||||||
|
Body: { message, context_orga_ids?: [] }
|
||||||
|
Rate limit: 10 req/IP/jour
|
||||||
|
Mistral Small (stream optionnel MVP)
|
||||||
|
Réponse: 200 { answer, sources: [{id, nom}] }
|
||||||
|
Budget check avant appel
|
||||||
|
|
||||||
|
GET /api/stats Statistiques pour bandeau bas du site
|
||||||
|
Réponse: 200 { total_orgas, total_avis,
|
||||||
|
budget_mois_eur, budget_max_eur,
|
||||||
|
budget_warning: bool }
|
||||||
|
Cacheable 1 min
|
||||||
|
──────────────────────────────────────────────────────────────────────────────
|
||||||
|
```
|
||||||
|
|
||||||
|
### Note sur /api/chatbot
|
||||||
|
|
||||||
|
Le chatbot (Mistral Small) reçoit les fiches publiées comme contexte RAG simplifié :
|
||||||
|
- Fetch les 50 fiches les plus récentes (ou résultat de recherche)
|
||||||
|
- Passe leur nom + description_enrichie + tags dans le prompt system
|
||||||
|
- Mistral Small répond en mode assistant "je connais cet écosystème"
|
||||||
|
- Pas de vector search en MVP — recherche par mots-clés suffit à ce stade
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Rate limiting / anti-abus
|
||||||
|
|
||||||
|
### Implémentation recommandée : fichier JSON local + cron reset
|
||||||
|
|
||||||
|
Redis serait plus robuste mais ajoute une dépendance. Pour le MVP, un fichier JSON par IP est suffisant.
|
||||||
|
|
||||||
|
```
|
||||||
|
Structure : /tmp/nav-ratelimit/{IP_hash}.json
|
||||||
|
{
|
||||||
|
"submit": { "count": 2, "date": "2026-04-14" },
|
||||||
|
"comment": { "count": 1, "date": "2026-04-14" },
|
||||||
|
"chatbot": { "count": 5, "date": "2026-04-14" }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Middleware Node.js `rateLimit(endpoint, maxPerDay)` :
|
||||||
|
1. Lire le fichier de l'IP (créer si absent)
|
||||||
|
2. Si `date` != aujourd'hui → reset tous les compteurs
|
||||||
|
3. Si `count[endpoint] >= max` → retourner 429
|
||||||
|
4. Sinon : incrémenter et sauvegarder
|
||||||
|
|
||||||
|
Cron journalier (00h01) : purger les fichiers de rate limit > 2 jours.
|
||||||
|
|
||||||
|
### Plafonds
|
||||||
|
|
||||||
|
| Endpoint | Max / IP / jour | Justification |
|
||||||
|
|-----------------|-----------------|---------------|
|
||||||
|
| /api/submit | 3 | Anti-spam soumission fiches |
|
||||||
|
| /api/comment | 5 | Anti-spam commentaires |
|
||||||
|
| /api/chatbot | 10 | Budget IA + anti-abus |
|
||||||
|
| /api/search | 100 | Pas d'IA, peu de risque |
|
||||||
|
| /api/orgs | illimité | Public, cacheable |
|
||||||
|
|
||||||
|
### Gestion IP derrière proxy
|
||||||
|
|
||||||
|
Si VPS derrière reverse proxy (Caddy, Nginx) : utiliser le header `X-Forwarded-For` pour l'IP réelle. S'assurer que le proxy est configuré pour le passer (`trusted_proxies` dans Caddy).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Worker — implémentation
|
||||||
|
|
||||||
|
### Choix : Option A — cron toutes les 5 min
|
||||||
|
|
||||||
|
**Recommandation : cron 5 min.**
|
||||||
|
|
||||||
|
Pourquoi pas le webhook NocoDB :
|
||||||
|
- NocoDB webhooks (v0.9x) sont peu fiables sur les installs self-hosted — déclenchements manqués documentés
|
||||||
|
- Le cron est plus simple à debugger (logs clairs, relance manuelle possible)
|
||||||
|
- 5 min de latence est acceptable pour un process qui sera de toute façon modéré manuellement
|
||||||
|
|
||||||
|
Mise en place : `crontab -e` sur le VPS, ou PM2 avec `cron: "*/5 * * * *"` si Node.
|
||||||
|
|
||||||
|
### Structure du script `worker.js`
|
||||||
|
|
||||||
|
```
|
||||||
|
Fichier : /opt/nav-worker/worker.js
|
||||||
|
|
||||||
|
IMPORTS :
|
||||||
|
- node-fetch ou axios (appels HTTP)
|
||||||
|
- crawl4ai (CLI local ou appel HTTP si installé comme service)
|
||||||
|
- nodemailer (email Jules)
|
||||||
|
- Zod (validation JSON Mistral)
|
||||||
|
|
||||||
|
CONSTANTES (depuis .env) :
|
||||||
|
NOCODB_URL, NOCODB_TOKEN, NOCODB_BASE_ID, NOCODB_TABLE_ORGAS
|
||||||
|
MISTRAL_API_KEY
|
||||||
|
EMAIL_JULES, SMTP_*
|
||||||
|
BUDGET_MAX_EUR (default: 20)
|
||||||
|
WORKER_LOCK_FILE (default: /tmp/nav-worker.lock)
|
||||||
|
|
||||||
|
FLOW PRINCIPAL :
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
// 1. Lock anti-overlap
|
||||||
|
if (lockExists()) { log("Worker déjà en cours"); return }
|
||||||
|
createLock()
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 2. Check budget global
|
||||||
|
const cumulMois = await getBudgetMoisCourant()
|
||||||
|
if (cumulMois >= BUDGET_MAX_EUR) {
|
||||||
|
log("Budget atteint, worker en pause")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Fetch rows pending
|
||||||
|
const rows = await fetchPendingRows()
|
||||||
|
if (rows.length === 0) return
|
||||||
|
|
||||||
|
let processedCount = 0
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
// Re-check budget avant chaque fiche
|
||||||
|
const budget = await getBudgetMoisCourant()
|
||||||
|
if (budget >= BUDGET_MAX_EUR) break
|
||||||
|
|
||||||
|
// 4. Scrape si URL présente
|
||||||
|
let scrapeContent = null
|
||||||
|
if (row.url && row.scrape_status === 'pending') {
|
||||||
|
try {
|
||||||
|
scrapeContent = await scrapeWithCrawl4ai(row.url, timeout=180000)
|
||||||
|
await updateRow(row.id, { scrape_status: 'scraped', scrape_content: scrapeContent })
|
||||||
|
} catch (e) {
|
||||||
|
await updateRow(row.id, { scrape_status: 'failed' })
|
||||||
|
// On continue quand même avec IA (sans contenu scrape)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Appel Mistral Nemo enrichissement
|
||||||
|
const enriched = await callMistralWithRetry(
|
||||||
|
buildEnrichmentPrompt(row, scrapeContent),
|
||||||
|
model='open-mistral-nemo',
|
||||||
|
maxRetries=2
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!enriched) {
|
||||||
|
await updateRow(row.id, { moderation_status: 'ai_error', ai_processed: true })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Normalisation tags
|
||||||
|
enriched.tags_fonction = enriched.tags_fonction
|
||||||
|
.map(normalizeTag)
|
||||||
|
.filter(Boolean)
|
||||||
|
|
||||||
|
// 7. Update NocoDB
|
||||||
|
await updateRow(row.id, {
|
||||||
|
description_enrichie: enriched.description_enrichie,
|
||||||
|
points_cles: JSON.stringify(enriched.points_cles),
|
||||||
|
tags_fonction: enriched.tags_fonction,
|
||||||
|
echelle: enriched.echelle,
|
||||||
|
territoire: enriched.territoire,
|
||||||
|
localisation_ville: enriched.localisation_ville,
|
||||||
|
moderation_status: 'ai_processed',
|
||||||
|
ai_processed: true,
|
||||||
|
ai_raw_output: JSON.stringify(enriched)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 8. Log usage tokens
|
||||||
|
await logUsage(enriched._usage, 'open-mistral-nemo', 'enrichissement', row.id)
|
||||||
|
|
||||||
|
processedCount++
|
||||||
|
}
|
||||||
|
|
||||||
|
// 9. Email Jules si nouvelles fiches prêtes
|
||||||
|
if (processedCount > 0) {
|
||||||
|
await emailJules(processedCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 10. Alerts budget
|
||||||
|
await checkBudgetAlerts()
|
||||||
|
|
||||||
|
} finally {
|
||||||
|
removeLock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
run().catch(err => {
|
||||||
|
log("Worker error:", err)
|
||||||
|
removeLock()
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Détail crawl4ai
|
||||||
|
|
||||||
|
crawl4ai s'installe via pip et expose une CLI ou un serveur HTTP local.
|
||||||
|
Appel recommandé : **serveur HTTP local sur le VPS** (démarré via Docker ou systemd).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Installation
|
||||||
|
pip install crawl4ai
|
||||||
|
crawl4ai-setup
|
||||||
|
|
||||||
|
# Démarrer comme service (optionnel)
|
||||||
|
crawl4ai serve --port 11235
|
||||||
|
```
|
||||||
|
|
||||||
|
Depuis Node :
|
||||||
|
```javascript
|
||||||
|
const res = await fetch('http://localhost:11235/crawl', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ url: row.url, max_pages: 1 }),
|
||||||
|
signal: AbortSignal.timeout(180000) // 3 min
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
return data.markdown || null
|
||||||
|
```
|
||||||
|
|
||||||
|
Si crawl4ai n'est pas disponible comme service : appel CLI via `execSync` avec timeout — moins propre, acceptable en MVP.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Fallbacks et gestion d'erreurs
|
||||||
|
|
||||||
|
### Matrice des cas d'erreur
|
||||||
|
|
||||||
|
```
|
||||||
|
Situation Action worker Status final
|
||||||
|
──────────────────────────────────────────────────────────────────────────────────
|
||||||
|
URL absente Passer directement à IA scrape_status=no_link
|
||||||
|
avec description_user moderation_status=ai_processed
|
||||||
|
|
||||||
|
Scrape timeout (>3 min) flag scrape_failed scrape_status=failed
|
||||||
|
IA appelée avec moderation_status=ai_processed
|
||||||
|
description_user seule (Jules voit le flag)
|
||||||
|
|
||||||
|
Scrape 403 / 404 Idem timeout scrape_status=failed
|
||||||
|
|
||||||
|
Scrape JS-heavy (SPA) crawl4ai gère JavaScript — si quand même vide :
|
||||||
|
traiter comme no_link scrape_status=failed
|
||||||
|
|
||||||
|
IA JSON mal formé (1er try) Retry 1 —
|
||||||
|
IA JSON mal formé (2e try) Retry 2 —
|
||||||
|
IA JSON mal formé (3e try) log erreur moderation_status=ai_error
|
||||||
|
ai_processed=true
|
||||||
|
|
||||||
|
IA timeout réseau Retry 2x (backoff 2s) ai_error si 3 échecs
|
||||||
|
|
||||||
|
URL invalide au submit Rejet 400 HTTP Fiche jamais créée
|
||||||
|
avant INSERT NocoDB
|
||||||
|
|
||||||
|
Email invalide au submit Accepter la fiche submitted_by_email vide
|
||||||
|
Email ignoré Pas d'email post-modération
|
||||||
|
|
||||||
|
Budget atteint mid-pipeline Fiche en cours termine Fiches suivantes en pending
|
||||||
|
Worker s'arrête Reprend au 1er du mois
|
||||||
|
|
||||||
|
Budget atteint au check Worker s'arrête Toutes les fiches en pending
|
||||||
|
avant le batch
|
||||||
|
──────────────────────────────────────────────────────────────────────────────────
|
||||||
|
```
|
||||||
|
|
||||||
|
### Fiches avec scrape_failed : review Jules
|
||||||
|
|
||||||
|
Jules voit dans NocoDB UI les fiches avec `scrape_status=failed` et `moderation_status=ai_processed`. Il peut :
|
||||||
|
- Approuver en l'état (description_user suffit)
|
||||||
|
- Ajouter une description manuelle dans `description_enrichie` avant d'approuver
|
||||||
|
- Rejeter si la fiche est trop vague
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Sécurité
|
||||||
|
|
||||||
|
### Gestion des secrets
|
||||||
|
|
||||||
|
Tous les secrets dans `.env` sur le VPS. Jamais dans le code source ni dans git.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# /opt/nav-worker/.env (chmod 600, owned root:root)
|
||||||
|
NOCODB_URL=http://localhost:8070
|
||||||
|
NOCODB_TOKEN=e9rUEwfUrE7mo_am0QAytwM0vCbwh4o0sisZIbHl
|
||||||
|
NOCODB_BASE_ID=pipilvsi7dibo80
|
||||||
|
NOCODB_TABLE_ORGAS=m08t7g5v4wch6wb
|
||||||
|
NOCODB_TABLE_AVIS=m4hub7cdutgec47
|
||||||
|
MISTRAL_API_KEY=sk-...
|
||||||
|
EMAIL_JULES=jules@...
|
||||||
|
SMTP_HOST=...
|
||||||
|
SMTP_PORT=587
|
||||||
|
SMTP_USER=...
|
||||||
|
SMTP_PASS=...
|
||||||
|
BUDGET_MAX_EUR=20
|
||||||
|
```
|
||||||
|
|
||||||
|
Ajouter `.env` au `.gitignore` impérativement.
|
||||||
|
|
||||||
|
### Validation des inputs (Zod)
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Schéma de validation POST /api/submit
|
||||||
|
const submitSchema = z.object({
|
||||||
|
nom: z.string().min(2).max(200),
|
||||||
|
url: z.string().url().optional().or(z.literal('')),
|
||||||
|
description_user: z.string().max(2000).optional(),
|
||||||
|
echelle: z.enum(['National','Régional','Départemental','Local']).optional(),
|
||||||
|
territoire: z.enum(['Métropole','Guadeloupe','Martinique','Guyane','Réunion','Mayotte']).optional(),
|
||||||
|
tags_fonction: z.array(z.enum([
|
||||||
|
'Juridique','Technique','Économique','Administratif',
|
||||||
|
'Chantier','Comptabilité','Prospection','RH','Santé mentale'
|
||||||
|
])).max(5).optional(),
|
||||||
|
localisation_ville: z.string().max(100).optional(),
|
||||||
|
submitted_by_email: z.string().email().optional().or(z.literal(''))
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Autres règles de sécurité
|
||||||
|
|
||||||
|
- **Pas d'eval/exec** sur le contenu scrapé — le markdown retourné par crawl4ai est passé comme string dans le prompt, jamais exécuté
|
||||||
|
- **Sanitisation** du contenu scrapé avant insertion en base : strip des balises HTML résiduelles (marked ou DOMPurify côté Node si nécessaire)
|
||||||
|
- **CSP headers** sur toutes les routes API :
|
||||||
|
```
|
||||||
|
Content-Security-Policy: default-src 'none'
|
||||||
|
X-Content-Type-Options: nosniff
|
||||||
|
X-Frame-Options: DENY
|
||||||
|
```
|
||||||
|
- **NocoDB exposé uniquement en localhost** (pas de port 8070 ouvert sur le pare-feu Hetzner)
|
||||||
|
- **Worker tourne avec un user non-root** sur le VPS (user dédié `nav-worker`)
|
||||||
|
- **Pas de logs des tokens Mistral** en clair dans les fichiers log système (les ai_raw_output vont en NocoDB, pas dans syslog)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes pour l'agent dev
|
||||||
|
|
||||||
|
### Ce qui est tranché dans cette spec
|
||||||
|
- Stack : Node.js + NocoDB API + Mistral API + crawl4ai
|
||||||
|
- Cron 5 min (pas webhook)
|
||||||
|
- Deux modèles Mistral : Nemo pour worker + filtre, Small pour chatbot
|
||||||
|
- Rate limiting fichier JSON (pas Redis)
|
||||||
|
- Lock file anti-overlap pour le worker
|
||||||
|
|
||||||
|
### Ce qui n'est pas dans cette spec (hors scope MVP)
|
||||||
|
- Interface admin custom (Jules utilise NocoDB UI directement pour la modération)
|
||||||
|
- Géocodage automatique (latitude/longitude) — à ajouter en V2.1
|
||||||
|
- Vector search / RAG avancé pour le chatbot — SQLite FTS ou Postgres pgvector en V2.1
|
||||||
|
- Streaming Mistral Small pour le chatbot — MVP retourne la réponse complète
|
||||||
|
- Export CSV ou API publique fiches — à ajouter selon demande communauté
|
||||||
|
- Tests automatisés — à ajouter avant toute mise en prod
|
||||||
|
|
||||||
|
### Questions ouvertes pour Jules avant de coder
|
||||||
|
1. **Formulaire de soumission** : outil existant (Tally, Formbricks) ou formulaire custom HTML ? Si Tally → l'endpoint /api/submit doit accepter le webhook Tally (format différent du body JSON standard)
|
||||||
|
2. **Email Jules** : serveur SMTP existant sur le VPS, ou service transactionnel externe (Brevo, Mailgun) ? Si externe → ajouter la clé API dans .env
|
||||||
|
3. **Environnement de déploiement** : le worker tourne-t-il dans Docker (Compose avec NocoDB) ou directement sur l'hôte (PM2 + cron) ?
|
||||||
|
4. **Version NocoDB** : v0.9x ou v1.x ? L'API de patch des records diffère légèrement entre les deux versions
|
||||||
|
5. **Domaine API** : même domaine que le front (reverse proxy Caddy) ou sous-domaine séparé (`api.nav.trans-former.fr`) ?
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Spec générée par ATIS le 2026-04-14. Alimenter un agent dev pour l'implémentation.*
|
||||||
289
V2-cadrage/G-prompt-dev-final.md
Normal file
289
V2-cadrage/G-prompt-dev-final.md
Normal file
@@ -0,0 +1,289 @@
|
|||||||
|
# G — Prompt dev final NAV V2
|
||||||
|
|
||||||
|
> **⚠ CE PROMPT EST MAINTENANT SPLITTÉ EN 3 SESSIONS DÉDIÉES**
|
||||||
|
> Voir plus bas le statut d'avancement et les prompts de reprise.
|
||||||
|
|
||||||
|
## État d'avancement (dernière MAJ : 2026-04-14)
|
||||||
|
|
||||||
|
```
|
||||||
|
[✓] Session 1 — Setup + Fondations (terminée 2026-04-14)
|
||||||
|
├── VPS setup (token nav-v2-worker, crawl4ai 0.8.6, .env)
|
||||||
|
├── Schéma NocoDB (organisations +19 champs, stats_usage créée)
|
||||||
|
├── Parsing A-biblio (93 entités, 72 géocodées)
|
||||||
|
├── Taxonomie révisée (10 fonctions, 3 échelles)
|
||||||
|
├── Palette figée (palette-nav-v2.md)
|
||||||
|
└── 96 fiches importées (bypass modération), 3 réservées test pipe IA
|
||||||
|
|
||||||
|
[⏸] Session 2 — Front (à lancer)
|
||||||
|
→ Prompt : H-prompt-session-2-front.md
|
||||||
|
→ Durée : 6-8h
|
||||||
|
→ Étapes : carte Leaflet + sidebar + fiche + formulaire soumission
|
||||||
|
|
||||||
|
[⏸] Session 3 — IA + Deploy (à lancer après S2)
|
||||||
|
→ Prompt : I-prompt-session-3-ia-deploy.md
|
||||||
|
→ Durée : 6-8h
|
||||||
|
→ Étapes : worker Mistral Nemo + chatbot + bandeau + /a-propos + deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
## Décisions validées Session 1
|
||||||
|
|
||||||
|
- **Palette** : A sobre institutionnel + bleu nuit #1a2238 à 60% opacité partout (cf. `palette-nav-v2.md`)
|
||||||
|
- **Taxonomie** :
|
||||||
|
- Échelle (3) : National / Régional (inclut Départemental) / Local
|
||||||
|
- Territoire : Métropole + 5 DOM-TOM (enrichissement ultérieur)
|
||||||
|
- Fonctions (10) : Juridique, Technique, Économique, Administratif, Chantier, Comptabilité, **Développement** (ex-Prospection), **Formation** (nouveau), **Gestion d'agence** (ex-RH élargi), Santé mentale
|
||||||
|
- **Liberapay** : `liberapay.com/trans-former.fr`, transparence origine dans `/a-propos`
|
||||||
|
- **From email modération** : `contact@trans-former.fr`
|
||||||
|
- **Seed** : bypass pour 96 fiches, 3 réservées test pipe IA en S3 (CNOA National, Archireport, Collectif Fil)
|
||||||
|
- **Circuit breaker 20€ dépassé** → bandeau "manque de fonds" + pédagogie "1€ = N requêtes"
|
||||||
|
- **CNOA** : 1 fiche National + 13 CROA Régional (antennes pins secondaires reportées V3)
|
||||||
|
|
||||||
|
## Points ouverts (cf. JOURNAL-V2.md)
|
||||||
|
|
||||||
|
- [ ] Rate limit chatbot (Session 2) : fichier JSON vs Redis
|
||||||
|
- [ ] Seuil email modération (Session 3) : reco 5 fiches pending
|
||||||
|
- [ ] Facteur CO2eq : 0.052 (RTE FR) ou 0.055 (ADEME 2024)
|
||||||
|
- [ ] MCP nocodb à reconfigurer (URL de base incorrecte)
|
||||||
|
- [ ] Conflit `rich` après install crawl4ai : vérifier si lightrag impacté
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⬇ PROMPT ORIGINAL CI-DESSOUS ⬇
|
||||||
|
|
||||||
|
> Conservé pour référence. Ne PAS relancer en bloc — utiliser H et I pour les sessions suivantes.
|
||||||
|
|
||||||
|
> **À lancer en session Claude Code dédiée, modèle Opus, avec autorisation de déléguer à des sous-agents Sonnet.**
|
||||||
|
> Autonomie : 7/10 (2 checkpoints Jules : validation palette + review fiche seed test).
|
||||||
|
> Durée estimée : 20-28h (worker + front + deploy + tests).
|
||||||
|
> Prérequis humains : Mistral key créée, Liberapay créé, ssh vps-hetzner OK.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Mission
|
||||||
|
|
||||||
|
Coder NAV V2 à partir des specs produites. La V2 ajoute à la V1 existante :
|
||||||
|
1. Carte de France interactive (Leaflet + OSM)
|
||||||
|
2. Sidebar filtres (échelle + fonction)
|
||||||
|
3. Page fiche détaillée partageable (`/fiche/[id]`)
|
||||||
|
4. Worker IA async qui enrichit les soumissions (Mistral Nemo)
|
||||||
|
5. Chatbot recherche sémantique (Mistral Small)
|
||||||
|
6. Filtre éthique commentaires
|
||||||
|
7. Bandeau bas stats (coût IA + dons + activité)
|
||||||
|
8. Onglets territoire (Métropole / Outre-mer)
|
||||||
|
|
||||||
|
Stack : Nuxt 3 + Tailwind + Leaflet + NocoDB + Mistral direct + Resend.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fichiers de référence (à LIRE en premier)
|
||||||
|
|
||||||
|
Tous dans `C:\Users\jules\Dropbox\ATIS - IPCJRA\0 INBOX\NAV-V2-recherches\` :
|
||||||
|
|
||||||
|
1. `A-biblio-ecosysteme-archi.md` — 94 entités seed à importer
|
||||||
|
2. `B-provider-ia-souverain.md` — Mistral Nemo + Small, circuit breaker 20€/mois
|
||||||
|
3. `C-systeme-dons.md` — Liberapay widget
|
||||||
|
4. `E-spec-frontend.md` — Wireframes ASCII, 28 composants, routes, states
|
||||||
|
5. `F-spec-pipe-collaboration.md` — Schéma DB, prompts IA, endpoints, fallbacks
|
||||||
|
6. `VPS-check.md` — NocoDB 0.301.5, Resend SMTP, crawl4ai à installer
|
||||||
|
|
||||||
|
Fichier V1 existant :
|
||||||
|
- `1 PROJETS/TECH - infra VPS, website pro, RAG/nav-carte/` (code Nuxt V1)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Credentials & endpoints
|
||||||
|
|
||||||
|
```
|
||||||
|
VPS : ssh vps-hetzner (port 4422)
|
||||||
|
NocoDB : http://localhost:8070 (interne VPS)
|
||||||
|
Base : pipilvsi7dibo80
|
||||||
|
Table orgas : m08t7g5v4wch6wb
|
||||||
|
Table avis : m4hub7cdutgec47
|
||||||
|
Token V1 : e9rUEwfUrE7mo_am0QAytwM0vCbwh4o0sisZIbHl
|
||||||
|
→ Créer un NOUVEAU token "nav-v2-worker" au premier démarrage
|
||||||
|
Mistral : api.mistral.ai (key Scale, stockée côté Jules)
|
||||||
|
Resend : smtp.resend.com:465 (password dans /opt/vps-kit/.env)
|
||||||
|
Deploy : /opt/nav-carte/ (systemd nav-carte.service)
|
||||||
|
Domaine : nav.trans-former.fr
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Arbitrages tranchés (ne pas remettre en cause)
|
||||||
|
|
||||||
|
**Taxonomie :**
|
||||||
|
- Échelle (mono, obligatoire) : National / Régional / Départemental / Local
|
||||||
|
- Fonction (multi 1-5, ordre=priorité) : Juridique / Technique / Économique / Administratif / Chantier / Comptabilité / Prospection / RH / Santé mentale
|
||||||
|
- Territoire (séparé) : Métropole + 5 DOM-TOM
|
||||||
|
|
||||||
|
**UX :**
|
||||||
|
- Fiche = page dédiée `/fiche/[id]`
|
||||||
|
- Chatbot = bottom-sheet (mobile ET desktop)
|
||||||
|
- Onglets territoire = toggle Métropole/Outre-mer → sous-onglets DOM-TOM
|
||||||
|
- Formulaire = HTML custom Nuxt (pas Tally)
|
||||||
|
- Libs UI : Headless UI + Tailwind
|
||||||
|
- Filtres encodés dans URL (`?echelle=...&fonctions=...&territoire=...`)
|
||||||
|
- Filtrage côté client (< 300 fiches)
|
||||||
|
|
||||||
|
**Stack IA :**
|
||||||
|
- Mistral Nemo pour worker enrichissement + filtre éthique commentaires
|
||||||
|
- Mistral Small pour chatbot recherche
|
||||||
|
- Circuit breaker 20€/mois (table `stats_usage` + check avant chaque call)
|
||||||
|
- Chat completions API classique, pas Mistral Agents
|
||||||
|
|
||||||
|
**Système don :**
|
||||||
|
- Liberapay (widget embed dans bandeau bas)
|
||||||
|
- URL : liberapay.com/nav-archi (à confirmer avec Jules)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Étapes
|
||||||
|
|
||||||
|
### Étape 0 — Setup (30 min)
|
||||||
|
|
||||||
|
- [ ] SSH VPS, vérifier NocoDB + Resend
|
||||||
|
- [ ] Créer un token NocoDB dédié "nav-v2-worker"
|
||||||
|
- [ ] Installer crawl4ai : `docker run ... crawl4ai` OU `pip install crawl4ai` selon l'environnement VPS
|
||||||
|
- [ ] Demander à Jules :
|
||||||
|
- La Mistral API key (stockée en `.env` sur VPS, jamais commitée)
|
||||||
|
- L'URL Liberapay finale
|
||||||
|
- [ ] **CHECKPOINT Jules** : proposer 3 palettes design (sobre / chaleureux / moderne) avec mockups colorés → validation
|
||||||
|
|
||||||
|
### Étape 1 — Schéma NocoDB (30 min)
|
||||||
|
|
||||||
|
- [ ] Étendre la table orgas avec les nouveaux champs (cf. F §2)
|
||||||
|
- [ ] Créer table `stats_usage` (circuit breaker IA)
|
||||||
|
- [ ] Créer table `scrape_queue` si besoin
|
||||||
|
- [ ] Test : insert/update via API token
|
||||||
|
|
||||||
|
### Étape 2 — Front carte + sidebar (3-4h)
|
||||||
|
|
||||||
|
- [ ] Installer vue-leaflet, Headless UI
|
||||||
|
- [ ] Composants : `NavMap`, `NavSidebar`, `EchelleFilter`, `FonctionFilter`, `TerritoireToggle`
|
||||||
|
- [ ] Wrapper Leaflet dans `<ClientOnly>` (SSR Nuxt)
|
||||||
|
- [ ] Géocoder les 94 fiches seed (Nominatim, batch script)
|
||||||
|
- [ ] Filtres encodés dans URL
|
||||||
|
- [ ] Responsive : drawer mobile, sidebar 320px desktop
|
||||||
|
|
||||||
|
### Étape 3 — Page fiche + commentaires (2-3h)
|
||||||
|
|
||||||
|
- [ ] Route `/fiche/[id]` avec SSR
|
||||||
|
- [ ] Composants : `FicheDetail`, `CommentSection`, `CommentForm`
|
||||||
|
- [ ] API : `GET /api/fiche/[id]`, `POST /api/comment`
|
||||||
|
- [ ] Filtre éthique commentaire sync (Mistral Nemo, < 2s)
|
||||||
|
- [ ] Meta SEO (title, description, og:image)
|
||||||
|
- [ ] Bouton retour carte avec restauration filtres (query params)
|
||||||
|
|
||||||
|
### Étape 4 — Worker IA enrichissement (4-5h)
|
||||||
|
|
||||||
|
- [ ] Script Node.js `worker/enrich.js` (cron systemd toutes les 5 min)
|
||||||
|
- [ ] Pipeline : fetch pending → scrape (crawl4ai, timeout 3min) → Mistral Nemo → update NocoDB
|
||||||
|
- [ ] Prompts exacts de F §3
|
||||||
|
- [ ] Troncature contenu scrapé à 4k tokens avant prompt
|
||||||
|
- [ ] Logs usage → table `stats_usage`
|
||||||
|
- [ ] Circuit breaker 20€ (check avant chaque call)
|
||||||
|
- [ ] Email Jules (Resend) quand N fiches en attente modération
|
||||||
|
- [ ] Fallback scrape échoué : flag `scrape_failed`, review manuel
|
||||||
|
|
||||||
|
### Étape 5 — Chatbot recherche (3-4h)
|
||||||
|
|
||||||
|
- [ ] Composant `ChatbotSheet` (bottom-sheet, Headless UI)
|
||||||
|
- [ ] Message onboarding (texte exact de E §6)
|
||||||
|
- [ ] Template requête (besoin / thématique / lieu)
|
||||||
|
- [ ] API `POST /api/chatbot` : Mistral Small + contexte JSON fiches
|
||||||
|
- [ ] Rate limit : 10 req/IP/jour (stockage fichier JSON ou Redis)
|
||||||
|
- [ ] Affichage résultats : fiches + explication 1-2 lignes
|
||||||
|
|
||||||
|
### Étape 6 — Formulaire soumission (2h)
|
||||||
|
|
||||||
|
- [ ] Composant `SubmitModal` ou page `/contribuer`
|
||||||
|
- [ ] Validation Zod côté client + serveur
|
||||||
|
- [ ] Champs : nom, URL, desc, échelle, fonctions (1-5), territoire, ville, email
|
||||||
|
- [ ] Message post-submit (E §9)
|
||||||
|
- [ ] Rate limit : 3/IP/jour
|
||||||
|
|
||||||
|
### Étape 7 — Bandeau bas + Liberapay (1-2h)
|
||||||
|
|
||||||
|
- [ ] Composant `BandeauBas`
|
||||||
|
- [ ] Gauche : data depuis `stats_usage` (coût mois, tokens, carbone estimé)
|
||||||
|
- [ ] Milieu : bouton "Soutenir NAV" → Liberapay
|
||||||
|
- [ ] Droite : compteurs semaine (dons, nouvelles fiches, requêtes)
|
||||||
|
- [ ] Carbone estimé : kWh × 0.052 kg CO2eq/kWh (facteur FR RTE)
|
||||||
|
|
||||||
|
### Étape 8 — Page /a-propos (1h)
|
||||||
|
|
||||||
|
- [ ] Contenu : NAV, souveraineté, gouvernance, transparence
|
||||||
|
- [ ] Badges "IA souveraine", "Hébergé en France", "Zéro cookie US"
|
||||||
|
- [ ] Lien vers formulaire + Liberapay
|
||||||
|
|
||||||
|
### Étape 9 — Deploy + tests (2-3h)
|
||||||
|
|
||||||
|
- [ ] Build production
|
||||||
|
- [ ] Deploy via git pull + pm2/systemd restart
|
||||||
|
- [ ] Vérifier DNS, HTTPS (Caddy déjà config)
|
||||||
|
- [ ] **CHECKPOINT Jules** : soumettre une fiche test → vérifier pipeline IA → valider → voir sur carte
|
||||||
|
- [ ] Tester chatbot avec requête réelle
|
||||||
|
- [ ] Tester mobile (iOS Safari + Android Chrome)
|
||||||
|
|
||||||
|
### Étape 10 — Import seed 94 fiches (1h)
|
||||||
|
|
||||||
|
- [ ] Script import : parser `A-biblio-ecosysteme-archi.md`
|
||||||
|
- [ ] Pour chaque entité : créer en NocoDB avec status `published` (bypass modération pour seed)
|
||||||
|
- [ ] Géocoder ville via Nominatim
|
||||||
|
- [ ] Vérifier que les marqueurs apparaissent sur la carte
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Délégation
|
||||||
|
|
||||||
|
Tu peux (et dois) déléguer les sous-tâches indépendantes à des sous-agents Sonnet. Exemples :
|
||||||
|
|
||||||
|
- **Sonnet 1** : setup NocoDB (étape 1) + géocoding seed
|
||||||
|
- **Sonnet 2** : composants front carte + sidebar (étape 2)
|
||||||
|
- **Sonnet 3** : worker IA (étape 4)
|
||||||
|
- **Sonnet 4** : chatbot (étape 5)
|
||||||
|
|
||||||
|
Toi (Opus) = pilote :
|
||||||
|
- Arbitrages design + UX
|
||||||
|
- Checkpoints Jules
|
||||||
|
- Assemblage final + deploy
|
||||||
|
- Debug quand ça coince
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Règles
|
||||||
|
|
||||||
|
- Mobile-first, testé à chaque étape
|
||||||
|
- Français avec accents partout (UI + code comments)
|
||||||
|
- Pas de Google Fonts externe (self-hosted ou system font)
|
||||||
|
- Pas de librairie UI lourde (Vuetify, Quasar) — Tailwind + Headless UI uniquement
|
||||||
|
- Commits atomiques par étape (ex: `feat(front): carte Leaflet + sidebar filters`)
|
||||||
|
- Secrets via `.env` sur VPS, jamais dans le code
|
||||||
|
- Si un point bloque > 30 min → reporter dans le journal de session et demander Jules
|
||||||
|
- Tester le pipeline IA complet sur 1 fiche avant de scaler
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Output attendu
|
||||||
|
|
||||||
|
1. V2 déployée sur nav.trans-former.fr
|
||||||
|
2. 94 fiches seed visibles sur la carte
|
||||||
|
3. Worker IA qui tourne (cron 5 min)
|
||||||
|
4. Chatbot fonctionnel
|
||||||
|
5. Bandeau bas avec stats en temps réel
|
||||||
|
6. Liberapay relié
|
||||||
|
7. Journal de session `/1 PROJETS/TECH - infra VPS.../nav-carte/JOURNAL-V2.md` avec :
|
||||||
|
- Temps passé par étape
|
||||||
|
- Décisions techniques prises
|
||||||
|
- Points bloquants rencontrés
|
||||||
|
- TODOs V3
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Questions ouvertes (à trancher par Jules AVANT lancement)
|
||||||
|
|
||||||
|
1. **Palette design** : sobre / chaleureux / moderne ? (checkpoint étape 0)
|
||||||
|
2. **URL Liberapay finale** : `nav-archi` ou `trans-former` ?
|
||||||
|
3. **From email modération** : `newsletter@trans-former.fr` (existant Resend) ou créer `nav@trans-former.fr` ?
|
||||||
|
4. **Seed fiches** : bypass modération IA pour les 94 (publication directe) ou les passer dans la pipe pour tester ?
|
||||||
|
→ Reco : bypass pour rapidité, passer 2-3 fiches dans la pipe en test
|
||||||
155
V2-cadrage/H-prompt-session-2-front.md
Normal file
155
V2-cadrage/H-prompt-session-2-front.md
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
# H — Prompt Session 2 : Front NAV V2
|
||||||
|
|
||||||
|
## État d'avancement (mis à jour 2026-04-14)
|
||||||
|
|
||||||
|
**Décisions tranchées :**
|
||||||
|
- A) Rate limit submit : **Redis** (côté propre)
|
||||||
|
- B) Cluster Leaflet : seuil **15 pins** (densité FR)
|
||||||
|
- C) Hiérarchie racine/antennes (CNOA/CROA, CAUE…) : **HORS SCOPE S2** → Session 2.5 dédiée (`L-prompt-session-2.5-hierarchie.md`)
|
||||||
|
- D) Session 1 : **terminée 2026-04-14** (~96 fiches seed)
|
||||||
|
- E) Sécurité API : proxy Nuxt + token `.env` VPS + rate limit IP. GET fiches public, POST modéré.
|
||||||
|
|
||||||
|
**Dispatch en cours (séquencement anti-conflits) :**
|
||||||
|
- Phase 1 — **Sonnet 1** : étape 2 (carte + sidebar) — EN COURS
|
||||||
|
- Checkpoint Jules : layout mobile + desktop
|
||||||
|
- Phase 2 — **Sonnet 2** (étape 3 fiche) + **Sonnet 3** (étape 6 contribuer) en parallèle après checkpoint 1
|
||||||
|
- Prompts : `J-prompt-session-2-fiche.md` et `K-prompt-session-2-contribuer.md`
|
||||||
|
- Checkpoint Jules : 1 fiche seed rendue (typo + espacements)
|
||||||
|
- Phase 3 (post-S2) — Session 2.5 hiérarchie : prompt `L-prompt-session-2.5-hierarchie.md`
|
||||||
|
|
||||||
|
**Note chemins :** specs sont dans `nav-carte/V2-cadrage/`, pas `0 INBOX/NAV-V2-recherches/` (le prompt original ci-dessous référence le mauvais chemin — corrigé dans J/K/L).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> **Session dédiée Claude Code, modèle Opus pilote avec délégation sous-agents Sonnet autorisée.**
|
||||||
|
> Prérequis : Session 1 terminée (schéma NocoDB en place, ~103 fiches importées).
|
||||||
|
> Durée estimée : 6-8h.
|
||||||
|
> Autonomie : 7/10 (checkpoints : validation layout sidebar mobile + rendu Leaflet).
|
||||||
|
|
||||||
|
## Mission
|
||||||
|
|
||||||
|
Construire la partie front de NAV V2 à partir du code Nuxt V1 existant :
|
||||||
|
1. Carte interactive (Leaflet + OSM)
|
||||||
|
2. Sidebar filtres (échelle + fonction + territoire)
|
||||||
|
3. Page fiche détaillée `/fiche/[id]` + commentaires
|
||||||
|
4. Formulaire de soumission (`/contribuer`)
|
||||||
|
|
||||||
|
## Fichiers de référence (à LIRE en premier)
|
||||||
|
|
||||||
|
Tous dans `C:\Users\jules\Dropbox\ATIS - IPCJRA\0 INBOX\NAV-V2-recherches\` :
|
||||||
|
- `G-prompt-dev-final.md` (vision globale V2, étapes 2, 3, 6)
|
||||||
|
- `E-spec-frontend.md` (wireframes ASCII, composants, routes, states)
|
||||||
|
- `F-spec-pipe-collaboration.md` (§2 schéma DB — pour comprendre les champs affichés)
|
||||||
|
- `palette-nav-v2.md` **SOURCE UNIQUE palette CSS** — respecter à la lettre
|
||||||
|
|
||||||
|
Journal projet : `1 PROJETS\TECH - infra VPS, website pro, RAG\nav-carte\JOURNAL-V2.md`
|
||||||
|
Code V1 existant (base de travail) : `1 PROJETS\TECH - infra VPS, website pro, RAG\nav-carte\`
|
||||||
|
|
||||||
|
## Taxonomie finale (ne pas remettre en cause, cf. JOURNAL-V2)
|
||||||
|
|
||||||
|
- Échelle : `National` / `Régional` / `Local`
|
||||||
|
- Territoire : `Métropole` + 5 DOM-TOM (Guadeloupe, Martinique, Guyane, La Réunion, Mayotte)
|
||||||
|
- Fonctions (10) : Juridique, Technique, Économique, Administratif, Chantier, Comptabilité, Développement, Formation, Gestion d'agence, Santé mentale
|
||||||
|
|
||||||
|
**UX options vides :** afficher les valeurs d'échelle/territoire sans fiches avec un compteur `(0)` pour inviter à contribuer (cohérent esprit collaboratif).
|
||||||
|
|
||||||
|
## Credentials
|
||||||
|
|
||||||
|
```
|
||||||
|
NocoDB accessible via API publique : https://nav.trans-former.fr/api/proxy/nocodb ?
|
||||||
|
(à décider — pour l'instant back-end Nuxt fait proxy via token .env)
|
||||||
|
Token nav-v2-worker : dans /opt/nav-carte/.env sur VPS
|
||||||
|
Base : pipilvsi7dibo80
|
||||||
|
Table orgas : m08t7g5v4wch6wb
|
||||||
|
Table stats_usage : mbbq7n47ixy19mc
|
||||||
|
```
|
||||||
|
|
||||||
|
## Étapes
|
||||||
|
|
||||||
|
### Étape 2 — Carte + sidebar (3-4h)
|
||||||
|
|
||||||
|
- [ ] Installer `@nuxtjs/leaflet` (ou `vue-leaflet/vue-leaflet` selon compat Nuxt 3) + `@headlessui/vue`
|
||||||
|
- [ ] Composants à créer :
|
||||||
|
- `NavMap.vue` — carte Leaflet wrapped dans `<ClientOnly>` (SSR)
|
||||||
|
- `NavSidebar.vue` — wrapper filtres
|
||||||
|
- `EchelleFilter.vue` — chips exclusifs 3 valeurs
|
||||||
|
- `FonctionFilter.vue` — chips multi-sélection 10 valeurs
|
||||||
|
- `TerritoireToggle.vue` — toggle Métropole/Outre-mer + sous-onglets DOM-TOM
|
||||||
|
- [ ] Filtres encodés dans URL : `?echelle=National&fonctions=Juridique,Technique&territoire=Métropole`
|
||||||
|
- [ ] Filtrage **côté client** (< 300 fiches)
|
||||||
|
- [ ] Responsive :
|
||||||
|
- Desktop ≥ 1024px : sidebar 320px fixe à gauche
|
||||||
|
- Mobile : drawer (Headless UI Dialog) + bouton flottant "Filtres"
|
||||||
|
- [ ] Pins Leaflet customisés :
|
||||||
|
- Pin standard : `--nav-primary` (60% opacité, bleu nuit)
|
||||||
|
- Pin prioritaire (fiche mise en avant) : `--nav-accent` (safran)
|
||||||
|
- [ ] Cluster markers si > 50 pins visibles (`leaflet.markercluster`)
|
||||||
|
|
||||||
|
### Étape 3 — Page fiche + commentaires (2-3h)
|
||||||
|
|
||||||
|
- [ ] Route : `/fiche/[id]` avec SSR
|
||||||
|
- [ ] Composants :
|
||||||
|
- `FicheDetail.vue` — affichage complet (nom, description_enrichie si dispo sinon description_user, tags, URL, localisation)
|
||||||
|
- `CommentSection.vue` — liste commentaires approuvés
|
||||||
|
- `CommentForm.vue` — formulaire + submit API
|
||||||
|
- [ ] API endpoints à créer/adapter :
|
||||||
|
- `GET /api/fiche/[id]` — proxy NocoDB
|
||||||
|
- `POST /api/comment` — filtre éthique Mistral Nemo sync (timeout 2s, cf. F §3)
|
||||||
|
- [ ] Méta SEO dynamiques : `<title>`, description, og:image (carte générée ou logo par défaut)
|
||||||
|
- [ ] Bouton retour carte avec restauration filtres (query params préservés)
|
||||||
|
|
||||||
|
### Étape 6 — Formulaire soumission (2h)
|
||||||
|
|
||||||
|
- [ ] Page `/contribuer` (ou modal `SubmitModal.vue`)
|
||||||
|
- [ ] Validation Zod client + serveur
|
||||||
|
- [ ] Champs :
|
||||||
|
- nom (required, min 3)
|
||||||
|
- url (optional, regex URL)
|
||||||
|
- description_user (required, 50-500 chars)
|
||||||
|
- echelle (required, enum)
|
||||||
|
- fonctions (required, 1-5 multi)
|
||||||
|
- territoire (required, enum)
|
||||||
|
- localisation_ville (optional)
|
||||||
|
- submitted_by_email (required, format email)
|
||||||
|
- [ ] Après submit :
|
||||||
|
- POST NocoDB avec `moderation_status: "pending"`, `ai_processed: false`
|
||||||
|
- Message confirmation (texte exact E §9)
|
||||||
|
- La fiche partira dans le worker IA (cron 5 min) qui tournera en Session 3
|
||||||
|
- [ ] Rate limit : 3 submits/IP/jour (fichier JSON ou Redis — Session 2 tranche)
|
||||||
|
|
||||||
|
### Checkpoints Jules
|
||||||
|
|
||||||
|
1. Après étape 2 : montrer rendu mobile + desktop pour validation layout sidebar
|
||||||
|
2. Après étape 3 : une fiche seed rendue → validation typo + espacements
|
||||||
|
|
||||||
|
## Délégation (Opus pilote)
|
||||||
|
|
||||||
|
- Sonnet 1 : composants carte + sidebar (étape 2)
|
||||||
|
- Sonnet 2 : page fiche + commentaires (étape 3)
|
||||||
|
- Sonnet 3 : formulaire + validation Zod (étape 6)
|
||||||
|
|
||||||
|
Lancer en parallèle si pas de dépendance, regrouper si besoin pour économiser fenêtres.
|
||||||
|
|
||||||
|
## Règles
|
||||||
|
|
||||||
|
- Mobile-first, testé à chaque étape
|
||||||
|
- Accents français partout
|
||||||
|
- Pas de Google Fonts (system font stack)
|
||||||
|
- Respect strict `palette-nav-v2.md` (tokens CSS reproduits en variables Tailwind ou CSS globales)
|
||||||
|
- Commits atomiques par étape
|
||||||
|
- Ne pas toucher au worker IA / chatbot / bandeau — réservé Session 3
|
||||||
|
|
||||||
|
## Output attendu
|
||||||
|
|
||||||
|
- V2 front navigable en local (`npm run dev`)
|
||||||
|
- ~103 fiches visibles sur la carte
|
||||||
|
- Filtres fonctionnels (URL sync)
|
||||||
|
- Page fiche par ID OK
|
||||||
|
- Formulaire de soumission écrit dans NocoDB (status pending)
|
||||||
|
- Journal mis à jour : `JOURNAL-V2.md` (décisions, points bloquants, TODOs S3)
|
||||||
|
|
||||||
|
## Questions à trancher
|
||||||
|
|
||||||
|
- Rate limit soumission : fichier JSON simple ou Redis installé sur VPS ?
|
||||||
|
- Clustering carte : activation automatique dès 50 pins, ou seuil différent ?
|
||||||
|
- Cas CNOA + 13 CROA : tous les CROA au même zoom désaturent la carte. Clustering les absorbera — à vérifier.
|
||||||
194
V2-cadrage/I-prompt-session-3-ia-deploy.md
Normal file
194
V2-cadrage/I-prompt-session-3-ia-deploy.md
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
# I — Prompt Session 3 : IA + Deploy NAV V2
|
||||||
|
|
||||||
|
> **Session dédiée Claude Code, modèle Opus pilote avec délégation sous-agents Sonnet autorisée.**
|
||||||
|
> Prérequis : Sessions 1 et 2 terminées (front navigable, fiches visibles, formulaire OK).
|
||||||
|
> Durée estimée : 6-8h.
|
||||||
|
> Autonomie : 6/10 (checkpoints : test pipeline IA sur 3 fiches + deploy final).
|
||||||
|
|
||||||
|
## Mission
|
||||||
|
|
||||||
|
Brancher la couche IA et déployer la V2 :
|
||||||
|
1. Worker IA async qui enrichit les soumissions (Mistral Nemo)
|
||||||
|
2. Chatbot recherche sémantique (Mistral Small)
|
||||||
|
3. Bandeau bas stats (coût IA + dons + activité)
|
||||||
|
4. Page `/a-propos` (souveraineté, gouvernance, transparence Liberapay)
|
||||||
|
5. Deploy production `nav.trans-former.fr`
|
||||||
|
6. Test pipeline IA sur les 3 fiches réservées
|
||||||
|
|
||||||
|
## Fichiers de référence (à LIRE en premier)
|
||||||
|
|
||||||
|
Tous dans `C:\Users\jules\Dropbox\ATIS - IPCJRA\0 INBOX\NAV-V2-recherches\` :
|
||||||
|
- `G-prompt-dev-final.md` (vision V2, étapes 4, 5, 7, 8, 9)
|
||||||
|
- `B-provider-ia-souverain.md` (Mistral Nemo + Small, circuit breaker 20€/mois)
|
||||||
|
- `C-systeme-dons.md` (Liberapay widget)
|
||||||
|
- `F-spec-pipe-collaboration.md` (§3 prompts IA exacts, §4 endpoints)
|
||||||
|
- `VPS-check.md` (deploy process)
|
||||||
|
- `palette-nav-v2.md` (palette bandeau + /a-propos)
|
||||||
|
- `seed-test-pipe-ia.json` (3 fiches CNOA/Archireport/Collectif Fil réservées pour test worker)
|
||||||
|
|
||||||
|
Journal projet : `1 PROJETS\TECH - infra VPS, website pro, RAG\nav-carte\JOURNAL-V2.md`
|
||||||
|
Code en cours : `1 PROJETS\TECH - infra VPS, website pro, RAG\nav-carte\`
|
||||||
|
|
||||||
|
## Décisions tranchées (Sessions 1 + 2 + pilote S3)
|
||||||
|
|
||||||
|
- Circuit breaker 20€/mois dépassé → **bandeau "manque de fonds" + CTA Liberapay + pédagogie "1€ = N requêtes" + transparence origine Liberapay**
|
||||||
|
- Transparence Liberapay (origine belge, asso non-lucrative, hébergement FR/UE éthique) → section dédiée dans `/a-propos`
|
||||||
|
- From email modération : `contact@trans-former.fr`
|
||||||
|
- Liberapay URL : `liberapay.com/trans-former.fr` (widgets fournis dans C-systeme-dons.md)
|
||||||
|
- **Seuil email modération : 5 fiches `pending`** → envoi mail Jules via Resend
|
||||||
|
- **Facteur CO2eq : 0.052 kg CO2/kWh (RTE FR)** pour calcul bandeau carbone
|
||||||
|
- **Scraping : crawl4ai seul, pas de fallback httpx+BS4** (diag VPS pilote : crawl4ai occupe 4.3 MB, ms-playwright absent donc crawl4ai tourne en mode statique léger — un seul chemin de code, plus simple à maintenir)
|
||||||
|
- **Rate limit chatbot : 10 req/IP/jour** (F §8, fichier JSON `/tmp/nav-ratelimit/{IP_hash}.json`, cron reset journalier)
|
||||||
|
- **Prompts Mistral Nemo + Small : copier-coller exact depuis F §3 et §4**, ne pas reformuler
|
||||||
|
- **Stack deploy : systemd + Caddy sur nav.trans-former.fr** (même domaine que V1, pas de sous-domaine API)
|
||||||
|
- **Endpoint soumission** : `/api/organisations` (POST JSON standard, déjà existant — S2 a utilisé `organisations.post.ts`)
|
||||||
|
|
||||||
|
## État S1 et S2 — à lire AVANT de coder
|
||||||
|
|
||||||
|
**Lecture obligatoire en premier** : `1 PROJETS/TECH - infra VPS, website pro, RAG/nav-carte/JOURNAL-V2.md`
|
||||||
|
|
||||||
|
Points d'attention :
|
||||||
|
- 96 fiches importées NocoDB, 3 réservées test pipe IA (CNOA, Archireport, Collectif Fil)
|
||||||
|
- 8 anciens records V1 (IDs 1-8, `moderation_status null`) à purger avant deploy prod
|
||||||
|
- Divergence taxonomie `moderation_status` tranchée : `approved`/`rejected` partout, `pending` = absence de validation
|
||||||
|
- Endpoint NocoDB correct : `/api/v1/db/meta/tables/{id}/columns` (v0.301.5)
|
||||||
|
- Disque VPS 76% (21/04) — surveiller pendant scraping, alerter si > 85%
|
||||||
|
- Conflit lib Python `rich` résolu avec `--ignore-installed` — vérifier au passage que lightrag tourne toujours (TODO S1)
|
||||||
|
|
||||||
|
## Préflight avant tout code (obligatoire)
|
||||||
|
|
||||||
|
1. Lire `JOURNAL-V2.md` en entier
|
||||||
|
2. Lire `F-spec-pipe-collaboration.md` §3, §4, §6 (circuit breaker), §7 (endpoints), §9 (worker)
|
||||||
|
3. `ssh vps-hetzner "cat /opt/nav-carte/.env | grep -E 'MISTRAL|NOCODB|RESEND'"` → vérifier toutes les clés présentes
|
||||||
|
4. `ssh vps-hetzner "df -h /"` → confirmer disque < 85%
|
||||||
|
5. Si une clé manque ou disque > 85% → STOP, remonter au pilote Jules
|
||||||
|
|
||||||
|
## Test coût AVANT de scaler (règle sacrée)
|
||||||
|
|
||||||
|
Étape 5 (test pipe IA) :
|
||||||
|
1. Lancer le worker sur **1 seule fiche** (CNOA)
|
||||||
|
2. Vérifier `stats_usage` : tokens_in, tokens_out, cout_eur
|
||||||
|
3. Extrapoler coût pour 96 fiches → **si > 1€ pour le batch test, STOP + ping Jules**
|
||||||
|
4. Si OK, lancer sur les 2 fiches restantes (Archireport, Collectif Fil)
|
||||||
|
|
||||||
|
## Credentials
|
||||||
|
|
||||||
|
```
|
||||||
|
VPS : ssh vps-hetzner (port 4422)
|
||||||
|
NocoDB : localhost:8070
|
||||||
|
Token nav-v2-worker : dans /opt/nav-carte/.env
|
||||||
|
Mistral API key : dans /opt/nav-carte/.env (MISTRAL_API_KEY)
|
||||||
|
Resend SMTP : /opt/vps-kit/.env
|
||||||
|
Deploy : /opt/nav-carte/ (systemd nav-carte.service)
|
||||||
|
Domaine : nav.trans-former.fr (Caddy config existante)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Étapes
|
||||||
|
|
||||||
|
### Étape 4 — Worker IA enrichissement (4-5h)
|
||||||
|
|
||||||
|
- [ ] Script `worker/enrich.js` (Node.js, lancé via systemd timer toutes les 5 min)
|
||||||
|
- [ ] Pipeline :
|
||||||
|
1. Fetch `organisations` WHERE `moderation_status=pending` AND `ai_processed=false` (LIMIT 5 par run)
|
||||||
|
2. Pour chaque fiche :
|
||||||
|
- Scrape URL via **crawl4ai** (timeout 3 min, mode statique) — si échec : flag `scrape_status=failed`, pas de fallback httpx+BS4
|
||||||
|
- Truncate contenu scrapé à 4k tokens
|
||||||
|
- Appel Mistral Nemo avec prompt **F §3 (copier-coller exact)**
|
||||||
|
- Parser réponse → update `description_enrichie`, `points_cles`, `tags_fonction` (si IA propose), `ai_raw_output`, `ai_processed=true`, `scrape_status`
|
||||||
|
- Log dans `stats_usage` (model, tokens_in, tokens_out, cout_eur, timestamp, orga_id)
|
||||||
|
3. Si **circuit breaker 20€/mois dépassé** : skip enrich, log, trigger bandeau "manque de fonds"
|
||||||
|
4. Si **5 fiches en attente modération** : email Jules via Resend (from `contact@trans-former.fr`)
|
||||||
|
5. Si scrape échoué : flag `scrape_status=failed`, review manuel
|
||||||
|
- [ ] Gestion erreurs : retry 2x, puis flag `scrape_status=failed`
|
||||||
|
|
||||||
|
### Étape 5 — Test pipeline IA sur 3 fiches (1-2h)
|
||||||
|
|
||||||
|
Utiliser `seed-test-pipe-ia.json` (3 entrées : CNOA, Archireport, Collectif Fil) :
|
||||||
|
- [ ] Injecter manuellement dans NocoDB avec `moderation_status=pending`, `ai_processed=false`
|
||||||
|
- [ ] Déclencher worker manuellement (pas attendre cron)
|
||||||
|
- [ ] Observer les 3 runs, documenter :
|
||||||
|
- Temps d'exécution par fiche
|
||||||
|
- Qualité `description_enrichie` et `points_cles`
|
||||||
|
- Fonctions détectées par l'IA vs fonctions Jules
|
||||||
|
- Coût Mistral (stats_usage)
|
||||||
|
- [ ] Produire `1 PROJETS/TECH - infra VPS.../nav-carte/PIPE-IA-DOC.md` : documentation précise du pipeline, input, output, prompts, paramètres — servira de base pour le futur **skill `/mistral-nemo-vps`**
|
||||||
|
|
||||||
|
### Étape 5bis — Chatbot recherche (3-4h)
|
||||||
|
|
||||||
|
- [ ] Composant `ChatbotSheet.vue` (bottom-sheet Headless UI, mobile + desktop)
|
||||||
|
- [ ] Message onboarding : texte exact E §6
|
||||||
|
- [ ] Template requête : champs besoin / thématique / lieu → prompt structuré
|
||||||
|
- [ ] API `POST /api/chatbot` :
|
||||||
|
- Input : question utilisateur + contexte JSON des fiches (top N matchs par taxonomie)
|
||||||
|
- Appel Mistral Small (prompt F §3)
|
||||||
|
- Output : fiches recommandées + explication 1-2 lignes par fiche
|
||||||
|
- [ ] Rate limit : 10 req/IP/jour, fichier JSON `/tmp/nav-ratelimit/{IP_hash}.json` (F §8, aligné avec S2)
|
||||||
|
|
||||||
|
### Étape 7 — Bandeau bas + Liberapay (1-2h)
|
||||||
|
|
||||||
|
- [ ] Composant `BandeauBas.vue`, fond `--nav-primary` 60%
|
||||||
|
- [ ] **Gauche** : stats live depuis `stats_usage`
|
||||||
|
- Coût mois en cours / 20€
|
||||||
|
- Nombre tokens utilisés
|
||||||
|
- Carbone estimé : kWh × **0.052 kg CO2eq/kWh (RTE FR)** — valeur tranchée
|
||||||
|
- [ ] **Milieu** : CTA "Soutenir NAV" → Liberapay widget (snippet C-systeme-dons.md)
|
||||||
|
- [ ] **Droite** : compteurs semaine (nouveaux dons, fiches ajoutées, requêtes chatbot)
|
||||||
|
- [ ] **Mode "manque de fonds"** (si circuit breaker dépassé) : bandeau bascule en mode "NAV a épuisé son budget IA ce mois — aide-nous à continuer" + pédagogie "1€ permet ~X requêtes" (calcul depuis prix Mistral)
|
||||||
|
|
||||||
|
### Étape 8 — Page `/a-propos` (1-2h)
|
||||||
|
|
||||||
|
- [ ] Sections :
|
||||||
|
- **NAV** — mission, esprit collaboratif, pour qui
|
||||||
|
- **Souveraineté** — IA française (Mistral), hébergement FR (Hetzner), zéro cookie US
|
||||||
|
- **Gouvernance** — qui tient le projet, transparence code (GitHub si public)
|
||||||
|
- **Transparence Liberapay** — origine (asso belge Liberapay Recurrent Donations ASBL), non lucrative, pas de cut par défaut, hébergement FR/UE éthique, modèle de dons récurrents
|
||||||
|
- **Transparence IA** — modèles utilisés, circuit breaker, coût mensuel visible sur bandeau
|
||||||
|
- [ ] Badges : "IA souveraine", "Hébergé en France", "Zéro cookie US"
|
||||||
|
- [ ] Lien vers formulaire + widget Liberapay
|
||||||
|
|
||||||
|
### Étape 9 — Deploy + tests (2-3h)
|
||||||
|
|
||||||
|
- [ ] Build production (`npm run build`)
|
||||||
|
- [ ] **Avant deploy** : purger les 8 anciens records V1 (IDs 1-8, `moderation_status null`) dans NocoDB
|
||||||
|
- [ ] Deploy via `git pull && systemctl restart nav-carte.service`
|
||||||
|
- [ ] Vérifier DNS `nav.trans-former.fr`, HTTPS Caddy (config V1 déjà présente — vérifier si routes V2 nécessitent ajustement)
|
||||||
|
- [ ] **Checkpoint Jules** : test manuel
|
||||||
|
- Soumettre une fiche test via `/contribuer`
|
||||||
|
- Vérifier passage dans worker (cron 5 min)
|
||||||
|
- Jules valide en NocoDB manuellement
|
||||||
|
- Vérifier apparition sur la carte
|
||||||
|
- [ ] Tester chatbot avec 2-3 requêtes réelles
|
||||||
|
- [ ] Tester mobile iOS Safari + Android Chrome
|
||||||
|
|
||||||
|
## Checkpoints Jules
|
||||||
|
|
||||||
|
1. Avant étape 5 : valider texte onboarding chatbot + prompt recherche
|
||||||
|
2. Étape 5 test pipe : valider qualité enrichissement sur 3 fiches
|
||||||
|
3. Étape 8 : review texte `/a-propos` (ton Jules appliqué)
|
||||||
|
4. Étape 9 : checkpoint deploy final + submit test
|
||||||
|
|
||||||
|
## Délégation (Opus pilote)
|
||||||
|
|
||||||
|
- Sonnet 1 : worker IA + test pipe 3 fiches + PIPE-IA-DOC (étapes 4-5)
|
||||||
|
- Sonnet 2 : chatbot (étape 5bis)
|
||||||
|
- Sonnet 3 : bandeau + /a-propos (étapes 7-8)
|
||||||
|
- Opus : deploy + checkpoint finaux + debug
|
||||||
|
|
||||||
|
## Règles
|
||||||
|
|
||||||
|
- Tester pipeline IA sur 1 fiche AVANT de scaler aux 3
|
||||||
|
- Circuit breaker 20€ = sacré, jamais dépasser
|
||||||
|
- Secrets via `.env`, jamais dans le code
|
||||||
|
- Si blocage > 30 min → reporter au pilote
|
||||||
|
- À la fin : mettre à jour JOURNAL-V2 avec backlog V3 détaillé
|
||||||
|
|
||||||
|
## Output attendu
|
||||||
|
|
||||||
|
1. V2 déployée sur `nav.trans-former.fr`
|
||||||
|
2. ~103 fiches sur la carte + 3 fiches enrichies par IA
|
||||||
|
3. Worker cron 5 min opérationnel
|
||||||
|
4. Chatbot fonctionnel
|
||||||
|
5. Bandeau live avec stats
|
||||||
|
6. Liberapay relié + page `/a-propos` complète
|
||||||
|
7. `PIPE-IA-DOC.md` produit (base skill `/mistral-nemo-vps`)
|
||||||
|
8. `JOURNAL-V2.md` final avec backlog V3
|
||||||
87
V2-cadrage/J-prompt-session-2-fiche.md
Normal file
87
V2-cadrage/J-prompt-session-2-fiche.md
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
# J — Prompt Sonnet 2 : Page fiche détaillée + commentaires
|
||||||
|
|
||||||
|
> **Lancer APRÈS validation checkpoint étape 2 (Sonnet 1).**
|
||||||
|
> Peut tourner en **parallèle avec Sonnet 3** (étape 6 contribuer) — pas de conflit attendu sur les fichiers.
|
||||||
|
|
||||||
|
## Mission
|
||||||
|
|
||||||
|
Construire la page fiche détaillée `/fiche/[id]` + section commentaires modérés.
|
||||||
|
|
||||||
|
## Contexte (lis AVANT)
|
||||||
|
|
||||||
|
**Racine projet** : `nav-carte/`
|
||||||
|
|
||||||
|
Specs (toutes dans `nav-carte/V2-cadrage/`) :
|
||||||
|
1. Spec session : `H-prompt-session-2-front.md` (section "Étape 3")
|
||||||
|
2. Wireframes : `E-spec-frontend.md` (page fiche)
|
||||||
|
3. Pipe collaboration / modération : `F-spec-pipe-collaboration.md` §3 (filtre éthique Mistral Nemo)
|
||||||
|
4. Palette : `palette-nav-v2.md`
|
||||||
|
5. Schéma DB : `F-spec-pipe-collaboration.md` §2
|
||||||
|
|
||||||
|
Code base :
|
||||||
|
- `nav-carte/pages/fiche/` (squelette V1 à enrichir)
|
||||||
|
- `nav-carte/components/OrgCard.vue` (réutilisable pour card en haut)
|
||||||
|
- `nav-carte/server/routes/` (proxy NocoDB existant)
|
||||||
|
- Note Sonnet 1 (à lire dans JOURNAL-V2.md) : conventions composants + résolutions de conflits
|
||||||
|
|
||||||
|
## Livrables
|
||||||
|
|
||||||
|
- [ ] Route SSR : `/fiche/[id]`
|
||||||
|
- [ ] Composants :
|
||||||
|
- `FicheDetail.vue` — affichage complet : nom, `description_enrichie` si dispo sinon `description_user`, tags fonctions, échelle, territoire, URL, localisation (mini-carte Leaflet)
|
||||||
|
- `CommentSection.vue` — liste commentaires `moderation_status: approved`, tri chronologique
|
||||||
|
- `CommentForm.vue` — formulaire (nom + email + commentaire 50-500 chars) + submit API
|
||||||
|
- [ ] API endpoints :
|
||||||
|
- `GET /api/fiche/[id]` — proxy NocoDB
|
||||||
|
- `POST /api/comment` — filtre éthique Mistral Nemo synchrone (timeout 2s, fallback : `moderation_status: pending` si timeout). Cf. F §3.
|
||||||
|
- [ ] Méta SEO dynamiques : `<title>{nom} — NAV`, description (extrait description), og:image (logo par défaut)
|
||||||
|
- [ ] Bouton "← retour carte" qui restaure les query params filtres
|
||||||
|
- [ ] Rate limit POST commentaire : Redis (cf. Sonnet 3 si déjà en place, sinon coordonner)
|
||||||
|
|
||||||
|
## Règles
|
||||||
|
|
||||||
|
- Accents français
|
||||||
|
- Palette respectée
|
||||||
|
- Mobile-first
|
||||||
|
- Pas de Google Fonts
|
||||||
|
- Pas de modif schéma NocoDB
|
||||||
|
- Pas de touche à : carte index, sidebar filtres, formulaire `/contribuer`, worker IA
|
||||||
|
|
||||||
|
## Délégation cascade autorisée
|
||||||
|
|
||||||
|
Sous-agents Task (Sonnet) pour : recherche pattern Mistral Nemo dans codebase, génération composant isolé, audit V1 fiche.
|
||||||
|
|
||||||
|
## Critère de fin
|
||||||
|
|
||||||
|
- `npm run dev` charge `/fiche/[id]` pour une fiche seed (ex : id=1)
|
||||||
|
- Commentaire posté apparaît avec status correct (approved si filtre OK, pending sinon)
|
||||||
|
- Bouton retour carte préserve filtres URL
|
||||||
|
- Mobile testé (devtools)
|
||||||
|
- Commits atomiques
|
||||||
|
|
||||||
|
## Doute / blocage
|
||||||
|
|
||||||
|
- Mistral Nemo non configuré → POST commentaire = `pending` direct + TODO dans journal
|
||||||
|
- Spec ambiguë → tranche au plus simple, note dans `JOURNAL-V2.md`
|
||||||
|
- Blocage dur → STOP + reporte
|
||||||
|
|
||||||
|
## Résumé attendu
|
||||||
|
|
||||||
|
```
|
||||||
|
## S2 Étape 3 — [DONE / BLOCKED]
|
||||||
|
|
||||||
|
### Livrables
|
||||||
|
- ✓ ... / ✗ ...
|
||||||
|
|
||||||
|
### Décisions
|
||||||
|
- ...
|
||||||
|
|
||||||
|
### À tester
|
||||||
|
- /fiche/1, /fiche/2, post commentaire, retour carte avec filtres
|
||||||
|
|
||||||
|
### Fichiers modifiés / Commits
|
||||||
|
- ...
|
||||||
|
|
||||||
|
### Coordination Sonnet 3
|
||||||
|
- Rate limit Redis : posé / à poser ?
|
||||||
|
```
|
||||||
105
V2-cadrage/K-prompt-session-2-contribuer.md
Normal file
105
V2-cadrage/K-prompt-session-2-contribuer.md
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
# K — Prompt Sonnet 3 : Formulaire `/contribuer` + Redis rate limit
|
||||||
|
|
||||||
|
> **Lancer APRÈS validation checkpoint étape 2 (Sonnet 1).**
|
||||||
|
> Peut tourner en **parallèle avec Sonnet 2** (étape 3 fiche) — pas de conflit attendu.
|
||||||
|
|
||||||
|
## Mission
|
||||||
|
|
||||||
|
Construire le formulaire de soumission de nouvelle organisation + setup Redis pour rate limiting.
|
||||||
|
|
||||||
|
## Contexte (lis AVANT)
|
||||||
|
|
||||||
|
**Racine projet** : `nav-carte/`
|
||||||
|
|
||||||
|
Specs (toutes dans `nav-carte/V2-cadrage/`) :
|
||||||
|
1. Spec session : `H-prompt-session-2-front.md` (section "Étape 6")
|
||||||
|
2. Wireframes formulaire : `E-spec-frontend.md` (page contribuer)
|
||||||
|
3. Pipe collaboration : `F-spec-pipe-collaboration.md` (workflow modération + statut `pending`)
|
||||||
|
4. Palette : `palette-nav-v2.md`
|
||||||
|
5. VPS infra (Redis dispo ?) : `V2-cadrage/VPS-check.md`
|
||||||
|
|
||||||
|
Code base :
|
||||||
|
- `nav-carte/pages/ajouter.vue` (formulaire V1 à reprendre / refondre)
|
||||||
|
- `nav-carte/server/routes/` (proxy NocoDB)
|
||||||
|
- Note Sonnet 1 dans JOURNAL-V2.md : conventions composants
|
||||||
|
|
||||||
|
## Livrables
|
||||||
|
|
||||||
|
- [ ] Page `/contribuer` (refonte de `ajouter.vue`) ou modal `SubmitModal.vue` (tu tranches selon UX cohérence)
|
||||||
|
- [ ] Validation **Zod** côté client + serveur
|
||||||
|
- [ ] Champs :
|
||||||
|
- `nom` (required, min 3)
|
||||||
|
- `url` (optional, regex URL)
|
||||||
|
- `description_user` (required, 50-500 chars)
|
||||||
|
- `echelle` (required, enum National/Régional/Local)
|
||||||
|
- `fonctions` (required, 1-5 multi parmi 10)
|
||||||
|
- `territoire` (required, enum Métropole + 5 DOM-TOM)
|
||||||
|
- `localisation_ville` (optional, geocoding Nominatim côté serveur si fourni)
|
||||||
|
- `submitted_by_email` (required, format email)
|
||||||
|
- [ ] Submit :
|
||||||
|
- POST `/api/submit` → NocoDB avec `moderation_status: "pending"`, `ai_processed: false`
|
||||||
|
- Message confirmation : texte exact `E-spec-frontend.md` §9
|
||||||
|
- Worker IA (cron 5 min) traitera plus tard — Session 3 (toi tu ne touches pas le worker)
|
||||||
|
- [ ] **Rate limit Redis** : 3 submits / IP / jour
|
||||||
|
- Setup Redis sur VPS (vérifier `VPS-check.md` — sinon `apt install redis-server` + config localhost only)
|
||||||
|
- Connexion depuis Nuxt (`ioredis` ou `redis` npm)
|
||||||
|
- En local dev : Redis en Docker ou fallback compteur en mémoire (TODO journal)
|
||||||
|
- [ ] Endpoint `/api/comment` (Sonnet 2) : ajouter rate limit Redis 5 commentaires/IP/jour — coordonne avec Sonnet 2 via JOURNAL-V2.md
|
||||||
|
|
||||||
|
## Règles
|
||||||
|
|
||||||
|
- Accents français
|
||||||
|
- Palette respectée
|
||||||
|
- Mobile-first
|
||||||
|
- Pas de Google Fonts
|
||||||
|
- Pas de touche à : carte, sidebar, page fiche détail, worker IA, schéma NocoDB
|
||||||
|
- Sécurité : pas de leak token NocoDB côté client (proxy Nuxt obligatoire)
|
||||||
|
|
||||||
|
## Délégation cascade autorisée
|
||||||
|
|
||||||
|
Sous-agents Task (Sonnet) pour : install Redis VPS via SSH, génération schéma Zod, recherche Nominatim API.
|
||||||
|
|
||||||
|
## Credentials VPS
|
||||||
|
|
||||||
|
- SSH alias : `vps-hetzner` (port 4422)
|
||||||
|
- Détails : `_System` ou cf. mémoire (reference_vps_ssh_access)
|
||||||
|
- App nav-carte : `/opt/nav-carte/`
|
||||||
|
|
||||||
|
## Critère de fin
|
||||||
|
|
||||||
|
- `npm run dev` charge `/contribuer`
|
||||||
|
- Soumission valide → fiche en NocoDB avec status pending
|
||||||
|
- Soumission invalide → erreurs Zod affichées
|
||||||
|
- 4ᵉ submit même IP/jour → 429 rate limit
|
||||||
|
- Redis tourne sur VPS (ou fallback documenté en local)
|
||||||
|
- Mobile testé
|
||||||
|
- Commits atomiques
|
||||||
|
|
||||||
|
## Doute / blocage
|
||||||
|
|
||||||
|
- Redis impossible à installer VPS → fallback fichier JSON simple (compteur IP/jour) + TODO journal
|
||||||
|
- Nominatim rate-limited → skip geocoding, store ville texte brut
|
||||||
|
- Spec ambiguë → tranche au plus simple, note JOURNAL-V2.md
|
||||||
|
- Blocage dur → STOP + reporte
|
||||||
|
|
||||||
|
## Résumé attendu
|
||||||
|
|
||||||
|
```
|
||||||
|
## S2 Étape 6 — [DONE / BLOCKED]
|
||||||
|
|
||||||
|
### Livrables
|
||||||
|
- ✓ ... / ✗ ...
|
||||||
|
|
||||||
|
### Décisions
|
||||||
|
- Redis : VPS / Docker local / fallback JSON ?
|
||||||
|
- Page vs modal : ...
|
||||||
|
|
||||||
|
### À tester
|
||||||
|
- /contribuer (submit valide + invalide + rate limit)
|
||||||
|
|
||||||
|
### Fichiers modifiés / Commits
|
||||||
|
- ...
|
||||||
|
|
||||||
|
### Coordination Sonnet 2
|
||||||
|
- Rate limit `/api/comment` : posé pour toi ou à toi de poser ?
|
||||||
|
```
|
||||||
102
V2-cadrage/L-prompt-session-2.5-hierarchie.md
Normal file
102
V2-cadrage/L-prompt-session-2.5-hierarchie.md
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
# L — Prompt Session 2.5 : Hiérarchie racine / antennes (CNOA, CAUE, syndicats, etc.)
|
||||||
|
|
||||||
|
> **Session dédiée — à lancer APRÈS S2 (carte + fiche + contribuer) et AVANT S3 (worker IA + déploiement).**
|
||||||
|
> Modèle conseillé : **Opus pilote** + délégation Sonnet (modif schéma + UI + filtrage non-trivial).
|
||||||
|
> Durée estimée : 3-4h.
|
||||||
|
|
||||||
|
## Mission
|
||||||
|
|
||||||
|
Introduire dans NAV V2 la notion de **fiche racine** (tête de réseau national) avec **antennes** dépendantes (régionales / départementales / locales).
|
||||||
|
|
||||||
|
### Cas métier couverts
|
||||||
|
|
||||||
|
- **CNOA** (Conseil National de l'Ordre des Architectes) ↔ 13 CROA régionaux
|
||||||
|
- **Réseau CAUE** (national) ↔ ~95 CAUE départementaux
|
||||||
|
- **Syndicats nationaux** (UNSFA, CNAJEP…) ↔ antennes locales
|
||||||
|
- Patrons identiques pour autres réseaux à venir
|
||||||
|
|
||||||
|
### Pourquoi
|
||||||
|
|
||||||
|
Sans hiérarchie :
|
||||||
|
- Carte FR désaturée (100+ pins CAUE qui se chevauchent)
|
||||||
|
- Filtre "Local" ne montre pas la racine → utilisateur perd le contexte
|
||||||
|
- Pas de breadcrumb pour naviguer racine ↔ antenne
|
||||||
|
- Soumissions redondantes (chaque antenne crée fiche déconnectée)
|
||||||
|
|
||||||
|
## Spec
|
||||||
|
|
||||||
|
### Schéma NocoDB (ajout 2 colonnes table `orgas`)
|
||||||
|
|
||||||
|
```
|
||||||
|
parent_id FK nullable → orgas.id (NULL pour racines et fiches indépendantes)
|
||||||
|
is_root bool (true si tête de réseau, sinon false)
|
||||||
|
```
|
||||||
|
|
||||||
|
Migration : script Python ou via UI NocoDB. Mettre à jour les ~96 fiches seed pour rattacher les CROA à CNOA, etc. (requiert mapping manuel, ~30 min).
|
||||||
|
|
||||||
|
### UI
|
||||||
|
|
||||||
|
**Fiche racine** (ex : CNOA)
|
||||||
|
- Pin carte : `--nav-accent` (safran) — déjà cas "prioritaire"
|
||||||
|
- Sur fiche détail : bandeau "Réseau de N antennes" + carte miniature des antennes + liste cliquable
|
||||||
|
- Apparaît dans filtre `échelle=National`
|
||||||
|
|
||||||
|
**Fiche antenne** (ex : CROA Île-de-France)
|
||||||
|
- Pin carte : `--nav-primary` standard
|
||||||
|
- Sur fiche détail : breadcrumb en haut "← CNOA (racine du réseau)"
|
||||||
|
- Apparaît dans filtre `échelle=Régional` ou `Local`
|
||||||
|
|
||||||
|
**Filtrage**
|
||||||
|
- Filtre `échelle=Local` : affiche antennes locales SANS masquer les racines (toujours pinables si onglet National actif)
|
||||||
|
- Cluster carte absorbe la désaturation quand 13+ enfants visibles dans un département/région
|
||||||
|
|
||||||
|
**Soumission**
|
||||||
|
- Formulaire `/contribuer` : ajouter champ optionnel "Cette organisation fait-elle partie d'un réseau ?" → autocomplete sur racines existantes
|
||||||
|
- Si racine non trouvée : créer en pending sans `parent_id`, modérateur lie manuellement après
|
||||||
|
|
||||||
|
## Livrables
|
||||||
|
|
||||||
|
- [ ] Migration schéma NocoDB (`parent_id`, `is_root`)
|
||||||
|
- [ ] Mapping seed : rattacher CROA à CNOA, autres réseaux identifiés
|
||||||
|
- [ ] Composants :
|
||||||
|
- `FicheRacineHeader.vue` — bandeau réseau + miniature carte
|
||||||
|
- `BreadcrumbReseau.vue` — "← Racine"
|
||||||
|
- `AutocompleteRacine.vue` — pour formulaire contribuer
|
||||||
|
- [ ] Logique filtrage hiérarchique (composable `useHierarchicalFilter.ts`)
|
||||||
|
- [ ] Adaptation `NavMap.vue` : pin racine vs antenne distincts
|
||||||
|
- [ ] Adaptation `FicheDetail.vue` : afficher bandeau ou breadcrumb selon `is_root`
|
||||||
|
- [ ] Adaptation `pages/contribuer.vue` : champ réseau parent
|
||||||
|
- [ ] Tests manuels : naviguer CNOA → CROA-IDF → retour CNOA, filtre échelle cohérent
|
||||||
|
|
||||||
|
## Règles
|
||||||
|
|
||||||
|
- Accents français
|
||||||
|
- Palette respectée
|
||||||
|
- Mobile-first
|
||||||
|
- Migration backward-compat : fiches sans `parent_id` continuent à fonctionner comme avant
|
||||||
|
- Pas de touche au worker IA / chatbot (Session 3)
|
||||||
|
|
||||||
|
## Délégation (si Opus pilote)
|
||||||
|
|
||||||
|
| Sous-agent | Tâche | Modèle |
|
||||||
|
|------------|-------|--------|
|
||||||
|
| Sonnet 1 | Migration schéma + mapping seed CROA/CAUE | Sonnet |
|
||||||
|
| Sonnet 2 | Composants UI hiérarchie + adaptation map/fiche | Sonnet |
|
||||||
|
| Sonnet 3 | Composable filtrage + adaptation formulaire | Sonnet |
|
||||||
|
|
||||||
|
## Critère de fin
|
||||||
|
|
||||||
|
- Carte affiche CNOA (safran) + 13 CROA (bleu)
|
||||||
|
- Click CROA → breadcrumb vers CNOA
|
||||||
|
- Click CNOA → liste 13 CROA cliquables
|
||||||
|
- Filtre `Local` n'efface pas CNOA si onglet National actif
|
||||||
|
- Soumission de nouvelle antenne CAUE → autocomplete trouve "Réseau CAUE"
|
||||||
|
- Aucune régression sur les fiches sans `parent_id`
|
||||||
|
|
||||||
|
## Hors scope (pour Session 3)
|
||||||
|
|
||||||
|
- Worker IA enrichissement automatique
|
||||||
|
- Chatbot
|
||||||
|
- Bandeau collaboration
|
||||||
|
- Déploiement VPS prod
|
||||||
|
- SEO/GEO
|
||||||
179
V2-cadrage/M-prompt-session-2-mobile.md
Normal file
179
V2-cadrage/M-prompt-session-2-mobile.md
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
# M — Prompt Sonnet 1.8 : Mobile UX dédié NAV V2
|
||||||
|
|
||||||
|
> **Lancer APRÈS Sonnet 1.7 (correctif desktop final).**
|
||||||
|
> Mission : refonte de l'expérience mobile selon spec Jules validée 2026-04-14.
|
||||||
|
|
||||||
|
## Racine projet
|
||||||
|
`C:\Users\jules\Dropbox\ATIS - IPCJRA\1 PROJETS\TECH - infra VPS, website pro, RAG\nav-carte\`
|
||||||
|
|
||||||
|
## État
|
||||||
|
|
||||||
|
Sonnet 1.7 a posé le desktop final (top search + onglets territoire haut + chatbot zone droite + sidebar filtres+fiches). Le mobile actuel = drawer générique. Toi tu refais le mobile selon la spec ci-dessous.
|
||||||
|
|
||||||
|
Lis AVANT :
|
||||||
|
- `git log --oneline -25`
|
||||||
|
- `JOURNAL-V2.md`
|
||||||
|
- Composants : `app.vue`, `pages/index.vue`, `NavSidebar.vue`, `NavMap.vue`, `FilterDrawer.vue`, `EchelleFilter.vue`, `FonctionFilter.vue`, `TerritoireTabs.vue`, `TopSearchBar.vue`, `ChatbotPlaceholder.vue`
|
||||||
|
|
||||||
|
## Spec mobile (< 1024px)
|
||||||
|
|
||||||
|
```
|
||||||
|
═══════════════════════════════════════════
|
||||||
|
[Logo NAV] [🔍] [✚ Contribuer] ← top nav, recherche s'étend au tap
|
||||||
|
═══════════════════════════════════════════
|
||||||
|
[ Métropole ] [ Outre-mer ] ← onglets territoire (sous top nav)
|
||||||
|
─────────────────────────────────────
|
||||||
|
|
||||||
|
CARTE LEAFLET ← haut, hauteur dynamique
|
||||||
|
(Métropole ou grille ~40-50% viewport par défaut
|
||||||
|
Outre-mer adaptée mobile)
|
||||||
|
|
||||||
|
─── ⇅ (poignée draggable - bonus) ───
|
||||||
|
☐ Nat ☐ Rég ☐ Loc ← échelle ultra compact (1 ligne)
|
||||||
|
[1] Jur ☐ Tech ☐ Eco ☐ Adm … ← fonctions inline, scroll horizontal
|
||||||
|
───────────────────────────────────
|
||||||
|
[card fiche 1] ← liste, scroll vertical, max espace
|
||||||
|
[card fiche 2]
|
||||||
|
[card fiche 3]
|
||||||
|
...
|
||||||
|
|
||||||
|
┌────┐
|
||||||
|
│ 💬 │ ← bouton flottant transparent
|
||||||
|
└────┘ bas-droite, ~56x56px
|
||||||
|
═══════════════════════════════════════════
|
||||||
|
```
|
||||||
|
|
||||||
|
### A) Top nav mobile
|
||||||
|
- `TopSearchBar.vue` reste fonctionnel — au tap, s'étend en pleine largeur
|
||||||
|
- "Contribuer" en bouton compact (icône + texte ou icône seule selon largeur)
|
||||||
|
- Pas de menu hamburger nécessaire (3 éléments suffisent)
|
||||||
|
|
||||||
|
### B) Onglets territoire mobile
|
||||||
|
- Identiques au desktop, juste ré-adaptés en largeur
|
||||||
|
- Placés sous le top nav, au-dessus de la carte
|
||||||
|
- Compact : barre horizontale full-width
|
||||||
|
|
||||||
|
### C) Zone carte mobile
|
||||||
|
- Métropole : carte Leaflet pleine largeur, hauteur ~40-50% viewport
|
||||||
|
- Outre-mer : grille 5 mini-cartes adaptée mobile (1 colonne empilée OU 2 colonnes selon largeur)
|
||||||
|
- Si 5 mini-cartes Leaflet trop lourd sur mobile → fallback 1 carte avec sélecteur dropdown du DOM-TOM affiché (note dans journal)
|
||||||
|
|
||||||
|
### D) Bonus poignée draggable (skip si non-trivial)
|
||||||
|
- Barre horizontale entre carte et tagging (icône ⇅ ou poignée style iOS bottom sheet)
|
||||||
|
- Drag vers le haut → réduit la carte à ~25%, étend la liste fiches
|
||||||
|
- Drag vers le bas → étend la carte à ~70%
|
||||||
|
- Skip si > 30 min de dev — note dans journal pour Session 3
|
||||||
|
|
||||||
|
### E) Tagging compact (entre carte et liste)
|
||||||
|
- Bandeau ultra fin
|
||||||
|
- Échelle : 3 checkboxes en ligne, taille mini (16-18px), labels courts ("Nat", "Rég", "Loc")
|
||||||
|
- Fonctions : inline, scroll horizontal (overflow-x: auto), max 1 ligne visible
|
||||||
|
- Chaque fonction : case + label compact
|
||||||
|
- Numéro priorité visible quand sélectionnée [1], [2], etc.
|
||||||
|
- Bouton "tout effacer" mini icône à droite si filtres actifs
|
||||||
|
|
||||||
|
### F) Liste fiches mobile
|
||||||
|
- Cards compactes (densité supérieure au desktop)
|
||||||
|
- Tap sur card → centre la carte sur la fiche + pin highlighted (animation léger zoom)
|
||||||
|
- Scroll vertical infini (toutes les fiches filtrées)
|
||||||
|
- Header "N résultats" sticky en haut de la liste
|
||||||
|
|
||||||
|
### G) Bouton chatbot flottant
|
||||||
|
- Position fixed bas-droite, ~56x56px
|
||||||
|
- Fond `--nav-primary` avec opacité ~85% (transparence)
|
||||||
|
- Icône 💬 ou MessageCircle
|
||||||
|
- Shadow légère
|
||||||
|
- Tap → ouvre **bottom sheet plein écran** :
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────┐
|
||||||
|
│ ← Retour 💬 NAV │ ← header sheet
|
||||||
|
├─────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ (zone discussion IA) │
|
||||||
|
│ placeholder pour S3 │
|
||||||
|
│ │
|
||||||
|
│ │
|
||||||
|
├─────────────────────────────────────────┤
|
||||||
|
│ [Pose ta question…] [➤] │ ← input + bouton envoi (disabled)
|
||||||
|
└─────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
- Composant `ChatbotSheet.vue` (ou réutiliser `ChatbotPlaceholder.vue` adapté)
|
||||||
|
- Animation slide-up depuis le bas
|
||||||
|
- Fermeture : tap "← Retour" ou backdrop ou swipe down
|
||||||
|
- Logique : strictement visuelle, pas d'IA branchée (Session 3)
|
||||||
|
|
||||||
|
### H) Suppression du drawer "Filtres" actuel
|
||||||
|
|
||||||
|
- L'ancien drawer `FilterDrawer.vue` n'a plus de raison d'être (filtres sont maintenant inline dans le flow mobile)
|
||||||
|
- Soit : supprimer FilterDrawer + bouton flottant "Filtres" associé
|
||||||
|
- Soit : transformer en `ChatbotSheet.vue` et brancher au bouton 💬
|
||||||
|
|
||||||
|
→ Tranche au plus propre.
|
||||||
|
|
||||||
|
## Règles
|
||||||
|
|
||||||
|
- Mobile-first
|
||||||
|
- Ne touche pas : page fiche détail, formulaire `/contribuer`, schéma NocoDB, worker IA
|
||||||
|
- Palette stricte
|
||||||
|
- Accents français
|
||||||
|
- Commits atomiques par bloc
|
||||||
|
- Update `JOURNAL-V2.md`
|
||||||
|
|
||||||
|
## Délégation cascade autorisée
|
||||||
|
|
||||||
|
Sous-agents Task pour : recherche pattern bottom sheet Vue, recherche pattern poignée draggable, recherche multi-Leaflet adaptation mobile.
|
||||||
|
|
||||||
|
## Critère de fin
|
||||||
|
|
||||||
|
- Mobile (< 1024px en devtools ou vrai téléphone) :
|
||||||
|
- Top nav avec recherche fonctionnelle
|
||||||
|
- Onglets territoire commutent vue carte
|
||||||
|
- Carte Leaflet en haut, lisible
|
||||||
|
- Tagging compact échelle + fonctions inline
|
||||||
|
- Liste fiches scrollable
|
||||||
|
- Tap card → centre carte
|
||||||
|
- Bouton chatbot flottant
|
||||||
|
- Tap chatbot → bottom sheet plein écran
|
||||||
|
- `npm run build` clean
|
||||||
|
- Aucune régression desktop (≥ 1024px)
|
||||||
|
- Commits poussés
|
||||||
|
- JOURNAL-V2 à jour
|
||||||
|
|
||||||
|
## Doute / blocage
|
||||||
|
|
||||||
|
- Multi-Leaflet 5 instances trop lourd mobile → fallback 1 carte + dropdown sélecteur DOM-TOM
|
||||||
|
- Poignée draggable non-trivial → skip + note S3
|
||||||
|
- Bottom sheet animation Vue compliqué → utiliser `<Teleport>` + transition CSS simple
|
||||||
|
- Spec ambiguë → tranche au plus simple, note journal
|
||||||
|
- Blocage dur → STOP + reporte
|
||||||
|
|
||||||
|
## Résumé attendu
|
||||||
|
|
||||||
|
```
|
||||||
|
## S2 Étape 2 — Mobile UX [DONE / BLOCKED]
|
||||||
|
|
||||||
|
### Ajustements
|
||||||
|
- ✓/✗ A) Top nav mobile (recherche)
|
||||||
|
- ✓/✗ B) Onglets territoire mobile
|
||||||
|
- ✓/✗ C) Zone carte (Métropole + Outre-mer adapté)
|
||||||
|
- ✓/✗ D) Poignée draggable (bonus) : fait / skipped
|
||||||
|
- ✓/✗ E) Tagging compact inline
|
||||||
|
- ✓/✗ F) Liste fiches + tap → centre carte
|
||||||
|
- ✓/✗ G) Bouton chatbot flottant + bottom sheet
|
||||||
|
- ✓/✗ H) Drawer Filtres supprimé/transformé
|
||||||
|
|
||||||
|
### Décisions
|
||||||
|
- Multi-Leaflet mobile : fait / fallback (raison)
|
||||||
|
- Poignée : fait / skipped (raison)
|
||||||
|
|
||||||
|
### À tester
|
||||||
|
- Devtools mobile (Pixel 7, iPhone 14 Pro)
|
||||||
|
- Si possible : vrai tel via npm run dev -- --host
|
||||||
|
|
||||||
|
### Fichiers / Commits
|
||||||
|
- ...
|
||||||
|
```
|
||||||
|
|
||||||
|
Go.
|
||||||
174
V2-cadrage/N-prompt-session-taxonomie-carte-unifiee.md
Normal file
174
V2-cadrage/N-prompt-session-taxonomie-carte-unifiee.md
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
# N — Session : Taxonomie v2 + Carte unifiée AEP
|
||||||
|
|
||||||
|
> **À lancer APRÈS déploiement prod V2 stable.**
|
||||||
|
> Bundle : refonte taxonomie + refonte zone carte + hiérarchie racine/antennes.
|
||||||
|
> Modèle : Opus pilote + délégation Sonnet.
|
||||||
|
> Durée estimée : 6-8h.
|
||||||
|
|
||||||
|
## Mission
|
||||||
|
|
||||||
|
Refonte de l'architecture navigation + taxonomie AEP pour supporter :
|
||||||
|
1. 2 modes d'usage (Entraide / Pratiques inspirantes) + prévoir 3ème futur (Bibliothèque RAG)
|
||||||
|
2. Hiérarchie racine/antennes (CNOA/CROA, CAUE, syndicats…)
|
||||||
|
3. Affichage unifié Métropole + DOM-TOM (plus d'onglet territoire)
|
||||||
|
|
||||||
|
## Contexte
|
||||||
|
|
||||||
|
- Racine : `nav-carte/`
|
||||||
|
- Spec Session 2 : `V2-cadrage/H-prompt-session-2-front.md` (bloc État d'avancement)
|
||||||
|
- Journal : `JOURNAL-V2.md`
|
||||||
|
- Session 2 livre le front complet (desktop + mobile), déployé en prod
|
||||||
|
|
||||||
|
## 3 chantiers
|
||||||
|
|
||||||
|
### CHANTIER 1 — Onglets top : modes d'usage
|
||||||
|
|
||||||
|
Remplacer les onglets `Métropole | Outre-mer` par :
|
||||||
|
|
||||||
|
```
|
||||||
|
[ 🤝 Entraide ] [ ✨ Pratiques inspirantes ] [ 📚 Bibliothèque RAG ]
|
||||||
|
actif actif (MVP) disabled (S4+)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Mode Entraide (défaut)** :
|
||||||
|
- Sidebar : échelle + fonctions (10 actuelles) + territoires
|
||||||
|
- Types affichés : Institution, Association, Syndicat, Formation, Média
|
||||||
|
- Logique : "J'ai besoin d'aide, où je trouve ?"
|
||||||
|
|
||||||
|
**Mode Pratiques inspirantes** :
|
||||||
|
- Sidebar : postures (multi-tag) + régions + fonctions secondaires
|
||||||
|
- Types affichés : Agence, Entreprise
|
||||||
|
- Logique : "Qui fait des choses qui m'inspirent ?"
|
||||||
|
|
||||||
|
**Mode Bibliothèque RAG** :
|
||||||
|
- Onglet visible mais désactivé (tooltip "à venir")
|
||||||
|
- Placeholder pour future intégration LightRAG/QMD
|
||||||
|
|
||||||
|
URL sync : `?mode=entraide|pratiques|rag`
|
||||||
|
|
||||||
|
### CHANTIER 2 — Nouvelle taxonomie
|
||||||
|
|
||||||
|
#### Dimension "Type d'organisation" (nouveau, required)
|
||||||
|
|
||||||
|
Ajout colonne NocoDB `type_organisation` (enum) :
|
||||||
|
- `institution` — CNOA, CAUE, ordres
|
||||||
|
- `association` — UNSFA, CNAJEP, assos thématiques
|
||||||
|
- `syndicat` — syndicats pro
|
||||||
|
- `agence` — agences d'archi / entreprises privées
|
||||||
|
- `formation` — ENSA, écoles
|
||||||
|
- `media` — AOC, magazines spécialisés
|
||||||
|
- `autre` — catch-all
|
||||||
|
|
||||||
|
Migration : assigner manuellement aux 96 fiches seed (mapping ~30 min, faire en script Python ou UI NocoDB).
|
||||||
|
|
||||||
|
#### Dimension "Postures" (nouveau, optional, multi)
|
||||||
|
|
||||||
|
Ajout colonne NocoDB `postures` (multi-select) :
|
||||||
|
- `regenerative` — Pratique régénérative
|
||||||
|
- `bas-carbone` — Bas carbone
|
||||||
|
- `cooperative` — SCOP / Coopérative
|
||||||
|
- `patrimoine` — Patrimoine / réhabilitation
|
||||||
|
- `ecoconstruction` — Écoconstruction
|
||||||
|
- `low-tech` — Low-tech
|
||||||
|
- `participatif` — Habitat participatif
|
||||||
|
- (liste évolutive — documenter le vocabulaire dans JOURNAL)
|
||||||
|
|
||||||
|
**Critères de sélection** (à définir avec Jules) : viser le **vérifiable** (signature charte X, labellisation Y, mention dans source Z) plutôt que le subjectif. Piste pour éviter la "guéguerre" politique.
|
||||||
|
|
||||||
|
#### Hiérarchie racine/antennes (cf. prompt L existant)
|
||||||
|
|
||||||
|
Ajout `parent_id` (FK nullable) + `is_root` (bool). Mapping CROA→CNOA, CAUE→Réseau CAUE.
|
||||||
|
|
||||||
|
### CHANTIER 3 — Carte unifiée (remplace tabs Métropole/Outre-mer)
|
||||||
|
|
||||||
|
Layout zone carte :
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────┬──────────────┐
|
||||||
|
│ │ Guadeloupe │
|
||||||
|
│ │ mini-Leaflet│
|
||||||
|
│ ├──────────────┤
|
||||||
|
│ FRANCE MÉTROPOLE │ Martinique │
|
||||||
|
│ (Leaflet principal) │ mini-Leaflet│
|
||||||
|
│ ~2/3 largeur zone carte ├──────────────┤
|
||||||
|
│ │ Guyane │
|
||||||
|
│ │ mini-Leaflet│
|
||||||
|
│ ├──────────────┤
|
||||||
|
│ │ La Réunion │
|
||||||
|
│ │ mini-Leaflet│
|
||||||
|
│ ├──────────────┤
|
||||||
|
│ │ Mayotte │ ← à confirmer Jules (4 ou 5 ?)
|
||||||
|
│ │ mini-Leaflet│
|
||||||
|
└─────────────────────────────────┴──────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
- Sidebar DOM-TOM à droite : colonne scrollable avec mini-cartes Leaflet (~160-180px hauteur chacune)
|
||||||
|
- Chaque mini-carte = vue centrée + zoomée sur son territoire avec ses pins propres
|
||||||
|
- Click sur pin OU card fiche (sidebar gauche) → centre la carte pertinente (principale ou mini)
|
||||||
|
- Titre au-dessus de chaque mini : "Guadeloupe", etc.
|
||||||
|
- Responsive mobile :
|
||||||
|
- Métropole en haut pleine largeur
|
||||||
|
- Mini-cartes DOM-TOM empilées dessous, scroll vertical
|
||||||
|
|
||||||
|
Suppression de `TerritoireTabs.vue` + `OutremerMap.vue` refactoré.
|
||||||
|
|
||||||
|
## Livrables
|
||||||
|
|
||||||
|
- [ ] Migration schéma NocoDB : `type_organisation`, `postures`, `parent_id`, `is_root`
|
||||||
|
- [ ] Mapping seed 96 fiches (script ou UI)
|
||||||
|
- [ ] Composants :
|
||||||
|
- `ModeTabs.vue` (3 onglets top, RAG disabled)
|
||||||
|
- `TypeFilter.vue` (selon mode actif)
|
||||||
|
- `PostureFilter.vue` (mode Pratiques)
|
||||||
|
- `DomTomSidebar.vue` (mini-cartes droite)
|
||||||
|
- `FicheRacineHeader.vue`, `BreadcrumbReseau.vue` (hiérarchie, cf. L)
|
||||||
|
- [ ] Adaptation sidebar selon mode (filtres différents)
|
||||||
|
- [ ] Adaptation NavMap : layout 2/3 + 1/3 DOM-TOM
|
||||||
|
- [ ] Pin racine (safran) vs antenne (bleu) sur carte
|
||||||
|
- [ ] Backward compat : fiches sans type/postures continuent à fonctionner (fallback "autre")
|
||||||
|
- [ ] Tests manuels : changement mode, filtrage par type/posture, hiérarchie CNOA→CROA, affichage DOM-TOM unifié
|
||||||
|
|
||||||
|
## Règles
|
||||||
|
|
||||||
|
- Accents français
|
||||||
|
- Palette stricte (`palette-nav-v2.md`)
|
||||||
|
- Mobile-first
|
||||||
|
- Pas de régression S2
|
||||||
|
- Commits atomiques par chantier (T1, T2, T3)
|
||||||
|
- Update `JOURNAL-V2.md`
|
||||||
|
|
||||||
|
## Délégation (Opus pilote)
|
||||||
|
|
||||||
|
| Sous-agent | Tâche | Modèle |
|
||||||
|
|-----------|-------|--------|
|
||||||
|
| Sonnet 1 | Migration schéma + mapping seed | Sonnet |
|
||||||
|
| Sonnet 2 | Onglets top + adaptation sidebar selon mode | Sonnet |
|
||||||
|
| Sonnet 3 | Carte unifiée (layout + DomTomSidebar) | Sonnet |
|
||||||
|
| Sonnet 4 (optionnel) | Hiérarchie UI (breadcrumb, bandeau racine) | Sonnet |
|
||||||
|
|
||||||
|
Parallélisation : Sonnet 1 d'abord (schéma), puis Sonnet 2+3+4 en parallèle.
|
||||||
|
|
||||||
|
## Critère de fin
|
||||||
|
|
||||||
|
- 3 onglets visibles, Entraide et Pratiques commutent la logique filtres+types
|
||||||
|
- Type et Postures filtrables, fiches seed migrées
|
||||||
|
- Carte unifiée : Métropole + 5 (ou 4) DOM-TOM visibles simultanément
|
||||||
|
- Hiérarchie fonctionnelle (CNOA → CROA-IDF → retour CNOA)
|
||||||
|
- Aucune régression S2 (recherche, filtres existants, /fiche, /contribuer)
|
||||||
|
- `npm run build` clean
|
||||||
|
- Commits poussés
|
||||||
|
- JOURNAL V2 à jour
|
||||||
|
|
||||||
|
## Questions à trancher avec Jules AVANT dispatch
|
||||||
|
|
||||||
|
1. **4 ou 5 DOM-TOM** ? (Guadeloupe, Martinique, Guyane, La Réunion, Mayotte = 5 ; Jules a mentionné 4)
|
||||||
|
2. **Critères Postures vérifiables** : liste des labels/chartes/sources faisant foi ?
|
||||||
|
3. **Fonctions du mode Pratiques** : garde-t-on les 10 fonctions ou on simplifie ?
|
||||||
|
4. **Modération** : fiches type "agence" passent-elles par le même workflow `pending` ou filtre plus strict ?
|
||||||
|
|
||||||
|
## Hors scope (Session 3)
|
||||||
|
|
||||||
|
- Worker IA enrichissement (parse URL + Mistral)
|
||||||
|
- Chatbot live
|
||||||
|
- Bibliothèque RAG (vectorisation + interface query)
|
||||||
|
- Déploiement final prod post-taxonomie
|
||||||
111
V2-cadrage/VPS-check.md
Normal file
111
V2-cadrage/VPS-check.md
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
# VPS check — NAV V2 prérequis
|
||||||
|
|
||||||
|
Date : 2026-04-14
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## NocoDB
|
||||||
|
|
||||||
|
- **Version exacte** : `0.301.5` (release `2026.04.0`)
|
||||||
|
- **Image Docker** : `nocodb/nocodb:latest` (tag latest, mais version réelle récupérée via API)
|
||||||
|
- **Port interne** : `8080` → mappé sur `8070` en externe
|
||||||
|
- **API version à utiliser** : **v1.x** (confirmé : `http://localhost:8080/api/v1/version` répond correctement)
|
||||||
|
- **Table IDs connues** (depuis `/opt/nav-carte/.env`) :
|
||||||
|
- `NUXT_ORG_TABLE_ID=m08t7g5v4wch6wb`
|
||||||
|
- `NUXT_AVIS_TABLE_ID=m4hub7cdutgec47`
|
||||||
|
- **Implications pour le dev** :
|
||||||
|
- API v1 (NocoDB >= 0.100.x) — utiliser `/api/v1/db/data/noco/{baseId}/{tableId}`
|
||||||
|
- Auth via token API (à récupérer dans NocoDB Settings > Tokens)
|
||||||
|
- Doc officielle v1 : https://docs.nocodb.com/developer-resources/rest-APIs/overview
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Listmonk SMTP
|
||||||
|
|
||||||
|
- **Provider** : Resend
|
||||||
|
- **Host** : `smtp.resend.com`
|
||||||
|
- **Port** : `465`
|
||||||
|
- **TLS** : oui (SSL implicite sur 465)
|
||||||
|
- **Username** : `resend`
|
||||||
|
- **From email** : `newsletter@trans-former.fr`
|
||||||
|
- **From name** : `Jules Neny`
|
||||||
|
- **Password** : **stocké dans** `/opt/vps-kit/.env` (variable `SMTP_PASSWORD`)
|
||||||
|
|
||||||
|
Jules : récupère-le avec :
|
||||||
|
```bash
|
||||||
|
ssh vps-hetzner "grep SMTP_PASSWORD /opt/vps-kit/.env"
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Réutilisation dans NAV V2** : OUI — utiliser directement les mêmes credentials Resend en SMTP
|
||||||
|
- Alternative légère : utiliser l'**API HTTP Resend** (plus simple qu'SMTP dans un worker Python/Node)
|
||||||
|
- API key Resend dans `/opt/vps-kit/.env` variable `RESEND_API_KEY` (à vérifier)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh vps-hetzner "grep -i resend /opt/vps-kit/.env | grep -v SMTP"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## NAV V1 — Deploy path
|
||||||
|
|
||||||
|
- **Chemin** : `/opt/nav-carte/`
|
||||||
|
- **Type** : Build Nuxt statique (pas de container Docker — `.output/` présent)
|
||||||
|
- **Config** : `/opt/nav-carte/.env`
|
||||||
|
- `NUXT_NOCODB_URL=http://localhost:8070`
|
||||||
|
- Table IDs ci-dessus
|
||||||
|
- **Pas de docker-compose** — servi probablement via Caddy ou node process direct
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bonus infra
|
||||||
|
|
||||||
|
- **Espace disque** : 38 GB total / **9.7 GB libre** (73% utilisé — attention)
|
||||||
|
- **RAM** : 7.5 GB total / 4.7 GB disponible (cache inclus) — correct
|
||||||
|
- **Swap** : 2 GB / 1.3 GB libre
|
||||||
|
- **crawl4ai installé** : **non** (ni container Docker, ni pip global)
|
||||||
|
|
||||||
|
### Stack Docker actif
|
||||||
|
|
||||||
|
| Container | Image | Port externe |
|
||||||
|
|-----------|-------|-------------|
|
||||||
|
| nocodb | nocodb/nocodb:latest | 8070 |
|
||||||
|
| astro-site | astro-site | 8090 |
|
||||||
|
| listmonk | listmonk/listmonk:v4.1.0 | 9000 |
|
||||||
|
| n8n | n8nio/n8n:1.40.0 | 5678 |
|
||||||
|
| coolify | coolify:4.0.0-beta.470 | 8000 |
|
||||||
|
| castopod | castopod:1.15.5 | 8081 |
|
||||||
|
| gitea | gitea:latest | 3030 |
|
||||||
|
| heyform | heyform community | 3000 |
|
||||||
|
| umami | umami:postgresql-v2.13.2 | 3001 |
|
||||||
|
| uptime-kuma | uptime-kuma:1.23.13 | 3002 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Recommandations pour NAV V2
|
||||||
|
|
||||||
|
- [x] **SMTP réutilisable : OUI** — Resend `smtp.resend.com:465`, username `resend`, from `newsletter@trans-former.fr`. Password dans `/opt/vps-kit/.env`
|
||||||
|
- [x] **NocoDB API à utiliser : v1.x** — endpoint `/api/v1/db/data/noco/{baseId}/{tableId}` avec token API
|
||||||
|
- [x] **crawl4ai : à installer** — pas présent sur le VPS. Option A : `docker run unclecode/crawl4ai` (container léger). Option B : `pip install crawl4ai` dans un venv Python sur le VPS.
|
||||||
|
- [x] **Espace disque : surveiller** — 9.7 GB libre. Si le worker NAV V2 génère des logs/cache, prévoir rotation.
|
||||||
|
- [x] **Alternative SMTP recommandée** : utiliser l'**API HTTP Resend** directement (pas SMTP) — plus fiable, pas de timeouts TLS, même provider.
|
||||||
|
|
||||||
|
```python
|
||||||
|
import resend
|
||||||
|
resend.api_key = "re_..."
|
||||||
|
resend.Emails.send({"from": "newsletter@trans-former.fr", "to": ["jules@..."], ...})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Actions manuelles requises (Jules)
|
||||||
|
|
||||||
|
1. **Récupérer le SMTP password Resend** :
|
||||||
|
```bash
|
||||||
|
ssh vps-hetzner "grep SMTP_PASSWORD /opt/vps-kit/.env"
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Récupérer le token API NocoDB** (pour le worker NAV V2) :
|
||||||
|
- Aller sur `http://[VPS]:8070` → Settings → API Tokens → créer un token `nav-v2-worker`
|
||||||
|
|
||||||
|
3. **Vérifier si une API key Resend existe** (alternative plus propre que SMTP) :
|
||||||
|
```bash
|
||||||
|
ssh vps-hetzner "grep -i resend /opt/vps-kit/.env"
|
||||||
|
```
|
||||||
100
V2-cadrage/palette-nav-v2.md
Normal file
100
V2-cadrage/palette-nav-v2.md
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
# Palette NAV V2 — Sobre institutionnel (validée 2026-04-14)
|
||||||
|
|
||||||
|
Direction : **A — Sobre institutionnel**, avec atténuation du bleu nuit à 60% d'opacité partout.
|
||||||
|
|
||||||
|
## Tokens CSS
|
||||||
|
|
||||||
|
```css
|
||||||
|
:root {
|
||||||
|
/* Fonds */
|
||||||
|
--nav-bg: #f8f6f1; /* fond principal, crème cassé */
|
||||||
|
--nav-bg-alt: #eee9df; /* fond carte, chips inactifs */
|
||||||
|
--nav-surface: #ffffff; /* cards, sidebar, header */
|
||||||
|
|
||||||
|
/* Bleu nuit — couleur primaire, utilisée avec opacité 60% */
|
||||||
|
--nav-primary-raw: 26, 34, 56; /* #1a2238 en RGB */
|
||||||
|
--nav-primary: rgba(26, 34, 56, 0.6); /* usage standard partout */
|
||||||
|
--nav-primary-solid: #1a2238; /* uniquement texte sur fond clair si lisibilité l'exige */
|
||||||
|
|
||||||
|
/* Accent — safran */
|
||||||
|
--nav-accent: #f5b342;
|
||||||
|
--nav-accent-soft: rgba(245, 179, 66, 0.85);
|
||||||
|
|
||||||
|
/* Texte */
|
||||||
|
--nav-text: #1a2238; /* texte principal : plein pour lisibilité */
|
||||||
|
--nav-text-muted: rgba(26, 34, 56, 0.55);
|
||||||
|
--nav-text-on-primary: #f8f6f1; /* texte sur fond bleu 60% */
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Règle — application du bleu à 60%
|
||||||
|
|
||||||
|
Le bleu nuit est **toujours utilisé à 60% d'opacité** sur les éléments visuels :
|
||||||
|
|
||||||
|
| Élément | Couleur | Note |
|
||||||
|
|----------------------|----------------------|-------------------------------|
|
||||||
|
| Bandeau bas (fond) | `--nav-primary` | rgba(26,34,56,0.6) |
|
||||||
|
| Pins carte | `--nav-primary` | border white 2px |
|
||||||
|
| Chips actifs (fond) | `--nav-primary` | texte safran dessus |
|
||||||
|
| Surlignages / hover | `--nav-primary` | transition douce |
|
||||||
|
| Header (si coloré) | `--nav-primary` | sinon blanc |
|
||||||
|
| Titres h1/h2 | `--nav-text` (plein) | lisibilité > esthétique |
|
||||||
|
| Texte courant | `--nav-text` (plein) | lisibilité > esthétique |
|
||||||
|
|
||||||
|
**Exception lisibilité** : texte, titres, et labels restent en bleu **plein** (`#1a2238`). Le 60% s'applique aux surfaces colorées, pas au texte. Si un texte doit apparaître sur fond bleu 60%, utiliser `--nav-text-on-primary` (crème).
|
||||||
|
|
||||||
|
## Swatches de référence
|
||||||
|
|
||||||
|
```
|
||||||
|
#f8f6f1 crème (fond)
|
||||||
|
#eee9df sable (fond alt)
|
||||||
|
#1a2238 bleu nuit (plein — texte/titres uniquement)
|
||||||
|
60% opa bleu nuit 60% (tout le reste : bandeau, pins, chips, surlignages)
|
||||||
|
#f5b342 safran (accent : CTA, pins prioritaires, liens actifs)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Typographie
|
||||||
|
|
||||||
|
- System font stack (pas de Google Fonts externe, cf. G règles)
|
||||||
|
- `font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;`
|
||||||
|
- Titres : `font-weight: 700`
|
||||||
|
- Courant : `font-weight: 400`
|
||||||
|
|
||||||
|
## États interactifs
|
||||||
|
|
||||||
|
- Hover : augmenter l'opacité du bleu à 0.75
|
||||||
|
- Active/selected : bleu plein en fond + texte safran
|
||||||
|
- Disabled : opacité 0.3
|
||||||
|
|
||||||
|
## Accessibilité
|
||||||
|
|
||||||
|
- Contraste texte plein bleu nuit sur crème : ratio > 10:1 (AAA)
|
||||||
|
- Bleu 60% sur crème : éviter d'y mettre du texte lisible — usage décoratif ou élément UI uniquement
|
||||||
|
- Safran sur bleu plein : ratio ~6:1 (AA)
|
||||||
|
- Focus ring : outline safran 2px, offset 2px
|
||||||
|
|
||||||
|
## Usage par composant
|
||||||
|
|
||||||
|
```
|
||||||
|
Header fond: --nav-surface (blanc)
|
||||||
|
logo: --nav-text plein
|
||||||
|
nav-links: --nav-text-muted
|
||||||
|
|
||||||
|
Sidebar fond: --nav-surface
|
||||||
|
titres filtres: --nav-text-muted UPPERCASE
|
||||||
|
chips inactifs: fond --nav-bg-alt, texte --nav-text
|
||||||
|
chips actifs: fond --nav-primary (60%), texte --nav-accent
|
||||||
|
|
||||||
|
Carte fond: --nav-bg-alt
|
||||||
|
pins standard: fond --nav-primary (60%), border white
|
||||||
|
pins prioritaires: fond --nav-accent, border --nav-primary
|
||||||
|
|
||||||
|
Bandeau bas fond: --nav-primary (60%)
|
||||||
|
texte: --nav-text-on-primary
|
||||||
|
CTA "Soutenir": fond --nav-accent, texte --nav-text plein
|
||||||
|
|
||||||
|
Fiche détail fond: --nav-surface
|
||||||
|
header fiche: bandeau --nav-primary (60%) avec crème dessus
|
||||||
|
tags fonction: chips --nav-bg-alt
|
||||||
|
bouton retour: --nav-primary (60%) hover --nav-accent
|
||||||
|
```
|
||||||
226
V2-cadrage/palettes-preview.html
Normal file
226
V2-cadrage/palettes-preview.html
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>NAV V2 — Palettes preview</title>
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #f0f0f0; padding: 24px; }
|
||||||
|
h1 { font-size: 20px; margin-bottom: 4px; }
|
||||||
|
.intro { color: #666; margin-bottom: 24px; font-size: 14px; }
|
||||||
|
.grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 24px; }
|
||||||
|
@media (max-width: 1200px) { .grid { grid-template-columns: 1fr; } }
|
||||||
|
|
||||||
|
.palette-card { background: white; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.08); }
|
||||||
|
.palette-label { padding: 12px 16px; background: #222; color: white; font-size: 13px; font-weight: 600; letter-spacing: 0.5px; }
|
||||||
|
.palette-label small { display: block; font-weight: 400; opacity: 0.7; margin-top: 2px; font-size: 11px; }
|
||||||
|
.swatches { display: flex; height: 40px; }
|
||||||
|
.swatch { flex: 1; display: flex; align-items: center; justify-content: center; font-size: 10px; font-family: monospace; color: rgba(0,0,0,0.5); }
|
||||||
|
|
||||||
|
/* Wireframe NAV */
|
||||||
|
.mockup { height: 480px; display: flex; flex-direction: column; font-size: 12px; }
|
||||||
|
.mock-header { padding: 12px 16px; display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid rgba(0,0,0,0.08); }
|
||||||
|
.mock-logo { font-weight: 700; font-size: 14px; }
|
||||||
|
.mock-nav-links { display: flex; gap: 12px; font-size: 11px; opacity: 0.7; }
|
||||||
|
.mock-body { flex: 1; display: flex; overflow: hidden; }
|
||||||
|
.mock-sidebar { width: 160px; padding: 12px; border-right: 1px solid rgba(0,0,0,0.08); font-size: 10px; }
|
||||||
|
.mock-filter-group { margin-bottom: 12px; }
|
||||||
|
.mock-filter-title { font-weight: 600; margin-bottom: 4px; font-size: 10px; text-transform: uppercase; letter-spacing: 0.5px; opacity: 0.6; }
|
||||||
|
.mock-chip { display: inline-block; padding: 3px 8px; border-radius: 12px; font-size: 9px; margin: 2px 2px 0 0; }
|
||||||
|
.mock-chip.active { font-weight: 600; }
|
||||||
|
.mock-map { flex: 1; position: relative; overflow: hidden; }
|
||||||
|
.mock-map-bg { position: absolute; inset: 0; opacity: 0.8; background-image:
|
||||||
|
radial-gradient(circle at 30% 40%, rgba(0,0,0,0.06) 0, transparent 40%),
|
||||||
|
radial-gradient(circle at 70% 60%, rgba(0,0,0,0.06) 0, transparent 40%); }
|
||||||
|
.mock-pin { position: absolute; width: 14px; height: 14px; border-radius: 50% 50% 50% 0; transform: rotate(-45deg); border: 2px solid white; }
|
||||||
|
.mock-pin.p1 { top: 25%; left: 40%; }
|
||||||
|
.mock-pin.p2 { top: 45%; left: 50%; }
|
||||||
|
.mock-pin.p3 { top: 60%; left: 35%; }
|
||||||
|
.mock-pin.p4 { top: 35%; left: 65%; }
|
||||||
|
.mock-pin.p5 { top: 55%; left: 70%; }
|
||||||
|
.mock-banner { padding: 10px 16px; font-size: 10px; display: flex; justify-content: space-between; align-items: center; border-top: 1px solid rgba(0,0,0,0.08); }
|
||||||
|
.mock-banner-btn { padding: 4px 10px; border-radius: 4px; font-size: 10px; font-weight: 600; }
|
||||||
|
|
||||||
|
/* ============ PALETTE A — SOBRE INSTITUTIONNEL ============ */
|
||||||
|
.palette-a .mockup { background: #f8f6f1; color: #1a2238; }
|
||||||
|
.palette-a .mock-header { background: white; }
|
||||||
|
.palette-a .mock-logo { color: #1a2238; }
|
||||||
|
.palette-a .mock-sidebar { background: white; }
|
||||||
|
.palette-a .mock-filter-title { color: #1a2238; }
|
||||||
|
.palette-a .mock-chip { background: #eee9df; color: #1a2238; }
|
||||||
|
.palette-a .mock-chip.active { background: #1a2238; color: #f5b342; }
|
||||||
|
.palette-a .mock-map { background: #eee9df; }
|
||||||
|
.palette-a .mock-pin { background: #1a2238; }
|
||||||
|
.palette-a .mock-pin.p2, .palette-a .mock-pin.p4 { background: #f5b342; }
|
||||||
|
.palette-a .mock-banner { background: #1a2238; color: #f8f6f1; }
|
||||||
|
.palette-a .mock-banner-btn { background: #f5b342; color: #1a2238; }
|
||||||
|
|
||||||
|
/* ============ PALETTE B — CHALEUREUX ARTISANAL ============ */
|
||||||
|
.palette-b .mockup { background: #f5ede2; color: #2d2418; }
|
||||||
|
.palette-b .mock-header { background: #fbf6ec; }
|
||||||
|
.palette-b .mock-logo { color: #9b4a2a; }
|
||||||
|
.palette-b .mock-sidebar { background: #fbf6ec; }
|
||||||
|
.palette-b .mock-filter-title { color: #2d5741; }
|
||||||
|
.palette-b .mock-chip { background: #ecd9c1; color: #2d2418; }
|
||||||
|
.palette-b .mock-chip.active { background: #9b4a2a; color: #f5ede2; }
|
||||||
|
.palette-b .mock-map { background: #e6d3b8; }
|
||||||
|
.palette-b .mock-pin { background: #9b4a2a; }
|
||||||
|
.palette-b .mock-pin.p2, .palette-b .mock-pin.p4 { background: #2d5741; }
|
||||||
|
.palette-b .mock-banner { background: #2d5741; color: #f5ede2; }
|
||||||
|
.palette-b .mock-banner-btn { background: #e6b35a; color: #2d2418; }
|
||||||
|
|
||||||
|
/* ============ PALETTE C — MODERNE SOUVERAIN ============ */
|
||||||
|
.palette-c .mockup { background: #fafafa; color: #1f2937; }
|
||||||
|
.palette-c .mock-header { background: white; }
|
||||||
|
.palette-c .mock-logo { color: #000091; }
|
||||||
|
.palette-c .mock-sidebar { background: white; }
|
||||||
|
.palette-c .mock-filter-title { color: #374151; }
|
||||||
|
.palette-c .mock-chip { background: #f3f4f6; color: #1f2937; border: 1px solid #e5e7eb; }
|
||||||
|
.palette-c .mock-chip.active { background: #000091; color: white; border-color: #000091; }
|
||||||
|
.palette-c .mock-map { background: #e5e7eb; }
|
||||||
|
.palette-c .mock-pin { background: #000091; }
|
||||||
|
.palette-c .mock-pin.p2, .palette-c .mock-pin.p4 { background: #ffcc00; border-color: #000091; }
|
||||||
|
.palette-c .mock-banner { background: #1f2937; color: #fafafa; }
|
||||||
|
.palette-c .mock-banner-btn { background: #ffcc00; color: #1f2937; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<h1>NAV V2 — Palettes preview</h1>
|
||||||
|
<p class="intro">3 directions visuelles appliquées au wireframe. Choisis celle qui matche l'énergie du projet.</p>
|
||||||
|
|
||||||
|
<div class="grid">
|
||||||
|
|
||||||
|
<!-- PALETTE A -->
|
||||||
|
<div class="palette-card palette-a">
|
||||||
|
<div class="palette-label">A — Sobre institutionnel<small>Service public, lisible, sérieux</small></div>
|
||||||
|
<div class="swatches">
|
||||||
|
<div class="swatch" style="background:#f8f6f1">#f8f6f1</div>
|
||||||
|
<div class="swatch" style="background:#eee9df">#eee9df</div>
|
||||||
|
<div class="swatch" style="background:#1a2238;color:#fff">#1a2238</div>
|
||||||
|
<div class="swatch" style="background:#f5b342">#f5b342</div>
|
||||||
|
</div>
|
||||||
|
<div class="mockup">
|
||||||
|
<div class="mock-header">
|
||||||
|
<span class="mock-logo">NAV</span>
|
||||||
|
<div class="mock-nav-links"><span>Carte</span><span>Proposer</span><span>À propos</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="mock-body">
|
||||||
|
<div class="mock-sidebar">
|
||||||
|
<div class="mock-filter-group">
|
||||||
|
<div class="mock-filter-title">Échelle</div>
|
||||||
|
<span class="mock-chip active">National</span>
|
||||||
|
<span class="mock-chip">Régional</span>
|
||||||
|
<span class="mock-chip">Local</span>
|
||||||
|
</div>
|
||||||
|
<div class="mock-filter-group">
|
||||||
|
<div class="mock-filter-title">Fonction</div>
|
||||||
|
<span class="mock-chip active">Juridique</span>
|
||||||
|
<span class="mock-chip">Technique</span>
|
||||||
|
<span class="mock-chip">Chantier</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mock-map">
|
||||||
|
<div class="mock-map-bg"></div>
|
||||||
|
<div class="mock-pin p1"></div><div class="mock-pin p2"></div>
|
||||||
|
<div class="mock-pin p3"></div><div class="mock-pin p4"></div>
|
||||||
|
<div class="mock-pin p5"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mock-banner">
|
||||||
|
<span>IA : 3,40 €/20 € · 12 fiches · 0,8 kg CO₂</span>
|
||||||
|
<span class="mock-banner-btn">Soutenir NAV</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- PALETTE B -->
|
||||||
|
<div class="palette-card palette-b">
|
||||||
|
<div class="palette-label">B — Chaleureux artisanal<small>Humain, proche archi réno, terre</small></div>
|
||||||
|
<div class="swatches">
|
||||||
|
<div class="swatch" style="background:#f5ede2">#f5ede2</div>
|
||||||
|
<div class="swatch" style="background:#9b4a2a;color:#fff">#9b4a2a</div>
|
||||||
|
<div class="swatch" style="background:#2d5741;color:#fff">#2d5741</div>
|
||||||
|
<div class="swatch" style="background:#e6b35a">#e6b35a</div>
|
||||||
|
</div>
|
||||||
|
<div class="mockup">
|
||||||
|
<div class="mock-header">
|
||||||
|
<span class="mock-logo">NAV</span>
|
||||||
|
<div class="mock-nav-links"><span>Carte</span><span>Proposer</span><span>À propos</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="mock-body">
|
||||||
|
<div class="mock-sidebar">
|
||||||
|
<div class="mock-filter-group">
|
||||||
|
<div class="mock-filter-title">Échelle</div>
|
||||||
|
<span class="mock-chip active">National</span>
|
||||||
|
<span class="mock-chip">Régional</span>
|
||||||
|
<span class="mock-chip">Local</span>
|
||||||
|
</div>
|
||||||
|
<div class="mock-filter-group">
|
||||||
|
<div class="mock-filter-title">Fonction</div>
|
||||||
|
<span class="mock-chip active">Juridique</span>
|
||||||
|
<span class="mock-chip">Technique</span>
|
||||||
|
<span class="mock-chip">Chantier</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mock-map">
|
||||||
|
<div class="mock-map-bg"></div>
|
||||||
|
<div class="mock-pin p1"></div><div class="mock-pin p2"></div>
|
||||||
|
<div class="mock-pin p3"></div><div class="mock-pin p4"></div>
|
||||||
|
<div class="mock-pin p5"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mock-banner">
|
||||||
|
<span>IA : 3,40 €/20 € · 12 fiches · 0,8 kg CO₂</span>
|
||||||
|
<span class="mock-banner-btn">Soutenir NAV</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- PALETTE C -->
|
||||||
|
<div class="palette-card palette-c">
|
||||||
|
<div class="palette-label">C — Moderne souverain<small>Tech-forward, vibe gouv.fr</small></div>
|
||||||
|
<div class="swatches">
|
||||||
|
<div class="swatch" style="background:#fafafa">#fafafa</div>
|
||||||
|
<div class="swatch" style="background:#000091;color:#fff">#000091</div>
|
||||||
|
<div class="swatch" style="background:#ffcc00">#ffcc00</div>
|
||||||
|
<div class="swatch" style="background:#1f2937;color:#fff">#1f2937</div>
|
||||||
|
</div>
|
||||||
|
<div class="mockup">
|
||||||
|
<div class="mock-header">
|
||||||
|
<span class="mock-logo">NAV</span>
|
||||||
|
<div class="mock-nav-links"><span>Carte</span><span>Proposer</span><span>À propos</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="mock-body">
|
||||||
|
<div class="mock-sidebar">
|
||||||
|
<div class="mock-filter-group">
|
||||||
|
<div class="mock-filter-title">Échelle</div>
|
||||||
|
<span class="mock-chip active">National</span>
|
||||||
|
<span class="mock-chip">Régional</span>
|
||||||
|
<span class="mock-chip">Local</span>
|
||||||
|
</div>
|
||||||
|
<div class="mock-filter-group">
|
||||||
|
<div class="mock-filter-title">Fonction</div>
|
||||||
|
<span class="mock-chip active">Juridique</span>
|
||||||
|
<span class="mock-chip">Technique</span>
|
||||||
|
<span class="mock-chip">Chantier</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mock-map">
|
||||||
|
<div class="mock-map-bg"></div>
|
||||||
|
<div class="mock-pin p1"></div><div class="mock-pin p2"></div>
|
||||||
|
<div class="mock-pin p3"></div><div class="mock-pin p4"></div>
|
||||||
|
<div class="mock-pin p5"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mock-banner">
|
||||||
|
<span>IA : 3,40 €/20 € · 12 fiches · 0,8 kg CO₂</span>
|
||||||
|
<span class="mock-banner-btn">Soutenir NAV</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1727
V2-cadrage/seed-94-fiches-v2.json
Normal file
1727
V2-cadrage/seed-94-fiches-v2.json
Normal file
File diff suppressed because it is too large
Load Diff
1603
V2-cadrage/seed-94-fiches.json
Normal file
1603
V2-cadrage/seed-94-fiches.json
Normal file
File diff suppressed because it is too large
Load Diff
114
V2-cadrage/seed-94-rapport.md
Normal file
114
V2-cadrage/seed-94-rapport.md
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
# NAV V2 — Rapport Seed 93 Entités
|
||||||
|
|
||||||
|
Date : 2026-04-14
|
||||||
|
Source : A-biblio-ecosysteme-archi.md
|
||||||
|
|
||||||
|
## Résumé
|
||||||
|
|
||||||
|
- **Entités parsées** : 93
|
||||||
|
- **Note** : A-biblio annonce 94 entités. Le tableau contient 93 entrées distinctes. Certaines entités multi-entrées (Architectes de l'Urgence vs Archi-Urgent.com, plusieurs pages MAF, Cité de l'Architecture x3, PUCA x2, Pavillon de l'Arsenal x2) expliquent l'écart si présent.
|
||||||
|
|
||||||
|
## Répartition par échelle
|
||||||
|
|
||||||
|
| Échelle | Nombre |
|
||||||
|
|---------|--------|
|
||||||
|
| National | 68 |
|
||||||
|
| Régional | 25 |
|
||||||
|
|
||||||
|
## Répartition par fonction (multi-valué, occurrence dans les fiches)
|
||||||
|
|
||||||
|
| Fonction | Occurrences |
|
||||||
|
|----------|-------------|
|
||||||
|
| Administratif | 50 |
|
||||||
|
| Technique | 49 |
|
||||||
|
| Prospection | 32 |
|
||||||
|
| Juridique | 29 |
|
||||||
|
| RH | 17 |
|
||||||
|
| Chantier | 10 |
|
||||||
|
| Comptabilité | 9 |
|
||||||
|
| Santé mentale | 9 |
|
||||||
|
| Économique | 1 |
|
||||||
|
|
||||||
|
## Territoire
|
||||||
|
|
||||||
|
Toutes les entités : **Métropole** (par défaut, conforme à A-biblio qui couvre uniquement la France métropolitaine).
|
||||||
|
|
||||||
|
## Géocodage (Nominatim, 1 req/sec)
|
||||||
|
|
||||||
|
| Résultat | Nombre |
|
||||||
|
|----------|--------|
|
||||||
|
| Réussis | 72 |
|
||||||
|
| Échecs | 0 |
|
||||||
|
| Sans ville (national/itinérant/outil) | 21 |
|
||||||
|
|
||||||
|
Fichier JSON : `seed-94-fiches.json`
|
||||||
|
|
||||||
|
## Cas ambigus ayant nécessité une décision
|
||||||
|
|
||||||
|
### 1. Entités sans ville (21 entités)
|
||||||
|
|
||||||
|
Catégories concernées :
|
||||||
|
- Outils SaaS sans siège (Archireport, OOTI, ArchiWIZARD, CYPETHERM, Construction21) → `ville: null`
|
||||||
|
- Podcasts sans ancrage (Dans la tête d'un archi, Fondations) → `ville: null`
|
||||||
|
- Recrutement national sans adresse précise (Archi-Jobs, Charette Service, Preference Search, FFP) → `ville: null`
|
||||||
|
- Associations nationales diffuses (Architectes Solidaires, Collectif Etc) → `ville: null`
|
||||||
|
- Santé mentale généraliste (Souffrance et Travail, France Burn-Out, Réseau Burn Out, Croix-Rouge, ASAfSAT) → `ville: null`
|
||||||
|
|
||||||
|
**Décision** : ne pas forcer lat/lon centre France — cela fausserait la carte Leaflet. Ces entités apparaîtront dans les filtres mais sans marqueur.
|
||||||
|
|
||||||
|
### 2. Réseau Burn Out (reseauburnout.org)
|
||||||
|
|
||||||
|
Signalé comme douteux dans A-biblio : actif principalement Belgique/Luxembourg.
|
||||||
|
**Décision** : inclus avec note d'avertissement dans `source_note`. À revoir par Jules avant mise en ligne.
|
||||||
|
|
||||||
|
### 3. Entités doublons fonctionnels (conservées distinctes)
|
||||||
|
|
||||||
|
A-biblio liste plusieurs fois les mêmes entités sous différentes URLs/angles :
|
||||||
|
- Architectes de l'Urgence + Archi-Urgent.com (2 entrées, même fondation)
|
||||||
|
- MAF principale + MAF Protection juridique (2 entrées, même mutuelle)
|
||||||
|
- Cité de l'Architecture + Formation continue + Podcasts (3 entrées, même institution)
|
||||||
|
- PUCA principale + PUCA Ressources en ligne (2 entrées)
|
||||||
|
- Pavillon de l'Arsenal + FAIRE — Pavillon de l'Arsenal (2 entrées)
|
||||||
|
**Décision** : conservées distinctes — à fusionner en post-import si souhaité.
|
||||||
|
|
||||||
|
### 4. CAUE classés Régional (pas Départemental)
|
||||||
|
|
||||||
|
Les CAUE sont des structures départementales mais à fonction régionale/locale selon la taxonomie NAV.
|
||||||
|
**Décision** : Régional pour les 5 CAUE listés — cohérent avec leur couverture multi-communale.
|
||||||
|
|
||||||
|
### 5. Isle d'Abeau
|
||||||
|
|
||||||
|
Géocodé via "Isle d'Abeau, France" → résultat Nominatim accepté (commune de Villefontaine, Isère).
|
||||||
|
|
||||||
|
### 6. Saint-Ouen (Archicréé)
|
||||||
|
|
||||||
|
Localisé Saint-Ouen selon A-biblio (88 bd de la Villette = adresse Paris 19e). Conservé tel quel.
|
||||||
|
|
||||||
|
### 7. Fonction "Économique" (FAIRE)
|
||||||
|
|
||||||
|
Utilisé pour FAIRE (programme de financement/accélération). Présent dans la taxonomie G. Maintenu.
|
||||||
|
|
||||||
|
## Décisions taxonomiques échelle
|
||||||
|
|
||||||
|
| Entité | Échelle retenue | Justification |
|
||||||
|
|--------|-----------------|---------------|
|
||||||
|
| CROA régionaux (8) | Régional | Compétence régionale explicite |
|
||||||
|
| CAUE (5 + FN CAUE) | Régional / National | Départemental mais couverture régionale |
|
||||||
|
| Plan Libre | Régional | Journal Occitanie uniquement |
|
||||||
|
| Réseau des MA | National | Fédère 32 MA nationales |
|
||||||
|
| Collectif Fil | Régional | Nantes, ancrage territorial clair |
|
||||||
|
| Collectif Etc | National | Itinérant, pas d'ancrage fixe |
|
||||||
|
| FAIRE | Régional | Paris / Arsenal spécifiquement |
|
||||||
|
|
||||||
|
## Recommandations — 3 fiches pour test pipe IA
|
||||||
|
|
||||||
|
1. **CNOA** (`architectes.org`) — entité principale, site riche et structuré, toutes les fonctions couvertes → test idéal du worker d'enrichissement Mistral Nemo
|
||||||
|
2. **Archireport** (`archireport.com`) — outil SaaS avec page produit structurée → teste la diversité de traitement "outil" vs "institution"
|
||||||
|
3. **Collectif Fil** (`collectif-fil.fr`) — petite structure régionale, site associatif simple → teste la robustesse sur sites moins riches + géolocalisation régionale
|
||||||
|
|
||||||
|
## URLs à vérifier manuellement avant import
|
||||||
|
|
||||||
|
- URLs CROA régionaux (Bretagne, AuRA, Nouvelle-Aquitaine, Grand Est, HDF) : reconstruites par pattern — confirmer sur architectes.org/les-17-conseils-regionaux
|
||||||
|
- Podcast "Dans la tête d'un archi" : URL ausha approximative
|
||||||
|
- Podcast "Fondations" (Solène Sillière) : URL Spotify approximative
|
||||||
|
- Réseau Burn Out : vérifier l'activité française réelle
|
||||||
54
V2-cadrage/seed-test-pipe-ia.json
Normal file
54
V2-cadrage/seed-test-pipe-ia.json
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"nom": "CNOA — Conseil National de l'Ordre des Architectes",
|
||||||
|
"url": "https://www.architectes.org",
|
||||||
|
"description": "Corps réglementaire regroupant les 30 500 architectes inscrits ; registre national, déontologie, formations, ressources juridiques.",
|
||||||
|
"echelle": "National",
|
||||||
|
"fonctions": [
|
||||||
|
"Juridique",
|
||||||
|
"Administratif",
|
||||||
|
"Gestion d'agence"
|
||||||
|
],
|
||||||
|
"territoire": "Métropole",
|
||||||
|
"ville": "Paris",
|
||||||
|
"lat": 48.8534951,
|
||||||
|
"lon": 2.3483915,
|
||||||
|
"status": "published",
|
||||||
|
"geocoding_failed": false,
|
||||||
|
"source_note": "A-biblio section nat-institutionnel"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nom": "Archireport",
|
||||||
|
"url": "https://www.archireport.com",
|
||||||
|
"description": "Logiciel suivi chantier 24 000 utilisateurs ; rapports, réserves, OPR, multi-plateformes.",
|
||||||
|
"echelle": "National",
|
||||||
|
"fonctions": [
|
||||||
|
"Chantier",
|
||||||
|
"Technique"
|
||||||
|
],
|
||||||
|
"territoire": "Métropole",
|
||||||
|
"ville": null,
|
||||||
|
"lat": null,
|
||||||
|
"lon": null,
|
||||||
|
"status": "published",
|
||||||
|
"geocoding_failed": false,
|
||||||
|
"source_note": "A-biblio section outils-plateformes"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nom": "Collectif Fil",
|
||||||
|
"url": "http://collectif-fil.fr",
|
||||||
|
"description": "Collectif architects-urbanistes-chercheurs fondé 2013 ; transformation des territoires habités.",
|
||||||
|
"echelle": "Régional",
|
||||||
|
"fonctions": [
|
||||||
|
"Technique",
|
||||||
|
"Développement"
|
||||||
|
],
|
||||||
|
"territoire": "Métropole",
|
||||||
|
"ville": "Nantes",
|
||||||
|
"lat": 47.2186371,
|
||||||
|
"lon": -1.5541362,
|
||||||
|
"status": "published",
|
||||||
|
"geocoding_failed": false,
|
||||||
|
"source_note": "A-biblio section entraide-solidaire"
|
||||||
|
}
|
||||||
|
]
|
||||||
285
app.vue
Normal file
285
app.vue
Normal file
@@ -0,0 +1,285 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex flex-col h-screen overflow-hidden" :class="{ dark: isDark }" style="background: var(--nav-bg);">
|
||||||
|
|
||||||
|
<!-- ═══════════════════════════════════════ TOP NAV GLOBAL -->
|
||||||
|
<header
|
||||||
|
class="flex items-center justify-between px-4 py-2.5 shrink-0 relative z-[9999] shadow-sm gap-3"
|
||||||
|
style="background: var(--nav-surface); border-bottom: 1px solid var(--nav-bg-alt);"
|
||||||
|
>
|
||||||
|
<!-- Logo -->
|
||||||
|
<a href="/" class="flex items-center gap-2 hover:opacity-90 transition-opacity shrink-0 group relative" title="Architecture d'Écologie Politique">
|
||||||
|
<div
|
||||||
|
class="h-7 px-2 rounded-lg flex items-center justify-center shrink-0"
|
||||||
|
style="background: var(--nav-primary-solid);"
|
||||||
|
>
|
||||||
|
<span class="font-bold text-xs tracking-tight" style="color: var(--nav-text-on-primary);">AEP</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<span class="font-bold text-base tracking-tight leading-tight" style="color: var(--nav-text);">AEP</span>
|
||||||
|
<span class="text-xs leading-tight hidden lg:inline" style="color: var(--nav-text-muted);">Architecture d'Écologie Politique</span>
|
||||||
|
</div>
|
||||||
|
<!-- Tooltip sm (quand le sous-titre lg est caché) -->
|
||||||
|
<div class="absolute left-0 top-full mt-2 px-2 py-1 rounded text-xs whitespace-nowrap pointer-events-none opacity-0 group-hover:opacity-100 transition-opacity lg:hidden z-50"
|
||||||
|
style="background: var(--nav-primary-solid); color: var(--nav-text-on-primary);">
|
||||||
|
Architecture d'Écologie Politique
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<!-- ── Onglets desktop (≥1024px) — remplace la barre de recherche ── -->
|
||||||
|
<nav class="hidden lg:flex flex-1 justify-center items-end gap-0 mx-6" aria-label="Navigation projets">
|
||||||
|
<NuxtLink
|
||||||
|
to="/"
|
||||||
|
class="nav-tab"
|
||||||
|
:class="{ 'nav-tab--active': route.path === '/' }"
|
||||||
|
>
|
||||||
|
Écosystème Entraide Architecture
|
||||||
|
</NuxtLink>
|
||||||
|
<NuxtLink
|
||||||
|
to="/agences"
|
||||||
|
class="nav-tab"
|
||||||
|
:class="{ 'nav-tab--active': route.path === '/agences' }"
|
||||||
|
>
|
||||||
|
Agences Inspirantes
|
||||||
|
<span class="nav-tab-badge">en construction</span>
|
||||||
|
</NuxtLink>
|
||||||
|
<NuxtLink
|
||||||
|
to="/rag"
|
||||||
|
class="nav-tab"
|
||||||
|
:class="{ 'nav-tab--active': route.path === '/rag' }"
|
||||||
|
>
|
||||||
|
RAG
|
||||||
|
<span class="nav-tab-badge">en construction</span>
|
||||||
|
</NuxtLink>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- ── Barre recherche mobile (640px–1024px) — masquée < 640px car accessible dans la sheet -->
|
||||||
|
<div class="hidden sm:flex lg:hidden flex-1 mx-2">
|
||||||
|
<label class="flex items-center gap-2 w-full px-3 py-1.5 rounded-xl border" style="background: var(--nav-bg); border-color: var(--nav-bg-alt);">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="color: var(--nav-text-muted); flex-shrink: 0;">
|
||||||
|
<circle cx="11" cy="11" r="8"/>
|
||||||
|
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
|
||||||
|
</svg>
|
||||||
|
<input
|
||||||
|
v-model="headerSearch"
|
||||||
|
type="search"
|
||||||
|
placeholder="Rechercher…"
|
||||||
|
class="flex-1 bg-transparent border-0 outline-none text-sm"
|
||||||
|
style="color: var(--nav-text); font-family: var(--nav-font);"
|
||||||
|
autocomplete="off"
|
||||||
|
@input="onHeaderSearch"
|
||||||
|
@keydown.enter="onHeaderSearch"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
v-if="headerSearch"
|
||||||
|
type="button"
|
||||||
|
@click.stop="clearHeaderSearch"
|
||||||
|
class="flex-shrink-0"
|
||||||
|
style="color: var(--nav-text-muted);"
|
||||||
|
aria-label="Effacer la recherche"
|
||||||
|
>
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions droite -->
|
||||||
|
<div class="flex items-center gap-2 shrink-0">
|
||||||
|
<NuxtLink
|
||||||
|
to="/a-propos"
|
||||||
|
class="px-3 py-1.5 rounded-lg text-sm font-medium transition-all hover:opacity-80 hidden md:inline-flex items-center gap-1"
|
||||||
|
style="color: var(--nav-text-muted);"
|
||||||
|
>
|
||||||
|
À propos
|
||||||
|
</NuxtLink>
|
||||||
|
<NuxtLink
|
||||||
|
to="/signaler"
|
||||||
|
class="px-3 py-1.5 rounded-lg text-sm font-medium transition-all hover:opacity-80 hidden lg:inline-flex items-center gap-1"
|
||||||
|
style="color: var(--nav-text-muted);"
|
||||||
|
>
|
||||||
|
Signaler
|
||||||
|
</NuxtLink>
|
||||||
|
<!-- Proposer une ressource -->
|
||||||
|
<NuxtLink
|
||||||
|
to="/contribuer"
|
||||||
|
class="px-3 py-1.5 rounded-lg text-sm font-semibold transition-all hover:opacity-80 hidden sm:inline-flex items-center gap-1"
|
||||||
|
style="background: var(--nav-accent); color: var(--nav-text);"
|
||||||
|
>
|
||||||
|
+ Proposer
|
||||||
|
</NuxtLink>
|
||||||
|
|
||||||
|
<!-- Toggle dark mode -->
|
||||||
|
<button
|
||||||
|
@click="toggleDark"
|
||||||
|
class="p-2 rounded-lg transition-all hover:opacity-80"
|
||||||
|
style="background: var(--nav-bg-alt); color: var(--nav-text-muted);"
|
||||||
|
:title="isDark ? 'Passer en mode clair' : 'Passer en mode sombre'"
|
||||||
|
:aria-label="isDark ? 'Mode clair' : 'Mode sombre'"
|
||||||
|
>
|
||||||
|
<svg v-if="!isDark" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
|
||||||
|
</svg>
|
||||||
|
<svg v-else width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<circle cx="12" cy="12" r="5"/>
|
||||||
|
<line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/>
|
||||||
|
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/>
|
||||||
|
<line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/>
|
||||||
|
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Mobile : contribuer icône -->
|
||||||
|
<NuxtLink
|
||||||
|
to="/contribuer"
|
||||||
|
class="sm:hidden p-2 rounded-lg"
|
||||||
|
style="background: var(--nav-accent); color: var(--nav-text);"
|
||||||
|
title="Contribuer une fiche"
|
||||||
|
aria-label="Contribuer"
|
||||||
|
>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
|
||||||
|
</svg>
|
||||||
|
</NuxtLink>
|
||||||
|
|
||||||
|
<!-- Hamburger mobile (lg:hidden) — toujours en dernier à droite -->
|
||||||
|
<div class="lg:hidden relative">
|
||||||
|
<button
|
||||||
|
@click="hamburgerOpen = !hamburgerOpen"
|
||||||
|
class="p-2 rounded-lg transition-all hover:opacity-80"
|
||||||
|
style="background: var(--nav-bg-alt); color: var(--nav-text-muted);"
|
||||||
|
:aria-label="hamburgerOpen ? 'Fermer le menu' : 'Menu'"
|
||||||
|
:aria-expanded="hamburgerOpen"
|
||||||
|
>
|
||||||
|
<svg v-if="!hamburgerOpen" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" aria-hidden="true">
|
||||||
|
<line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/>
|
||||||
|
</svg>
|
||||||
|
<svg v-else width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" aria-hidden="true">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
v-if="hamburgerOpen"
|
||||||
|
class="absolute right-0 top-full mt-1 rounded-lg shadow-lg min-w-[210px] py-1"
|
||||||
|
style="background: var(--nav-surface); border: 1px solid var(--nav-bg-alt); z-index: 9999;"
|
||||||
|
@click="hamburgerOpen = false"
|
||||||
|
>
|
||||||
|
<NuxtLink to="/" class="block px-4 py-2.5 text-sm font-medium transition-opacity hover:opacity-70" :style="route.path === '/' ? 'color: var(--nav-primary-solid); font-weight: 700;' : 'color: var(--nav-text);'">Écosystème Entraide Architecture</NuxtLink>
|
||||||
|
<NuxtLink to="/agences" class="block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" style="color: var(--nav-text);">Agences Inspirantes</NuxtLink>
|
||||||
|
<NuxtLink to="/rag" class="block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" style="color: var(--nav-text);">RAG</NuxtLink>
|
||||||
|
<div style="height: 1px; background: var(--nav-bg-alt); margin: 4px 0;"></div>
|
||||||
|
<NuxtLink to="/a-propos" class="block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" style="color: var(--nav-text-muted);">À propos</NuxtLink>
|
||||||
|
<NuxtLink to="/signaler" class="block px-4 py-2.5 text-sm transition-opacity hover:opacity-70" style="color: var(--nav-text-muted);">Signaler</NuxtLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Contenu page (flex-1 pour remplir l'espace) -->
|
||||||
|
<div class="flex-1" :class="route.path === '/' ? 'overflow-hidden' : 'overflow-y-auto'">
|
||||||
|
<NuxtPage />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bandeau bas — transparence IA + soutien + compteurs semaine -->
|
||||||
|
<BandeauBas />
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const router = useRouter()
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
|
const hamburgerOpen = ref(false)
|
||||||
|
watch(() => route.path, () => { hamburgerOpen.value = false })
|
||||||
|
|
||||||
|
// ── Dark mode ─────────────────────────────────────────────────────────────
|
||||||
|
const isDark = ref(false)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
const stored = localStorage.getItem('aep_theme')
|
||||||
|
if (stored === 'dark') {
|
||||||
|
isDark.value = true
|
||||||
|
document.documentElement.classList.add('dark')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function toggleDark() {
|
||||||
|
isDark.value = !isDark.value
|
||||||
|
if (isDark.value) {
|
||||||
|
document.documentElement.classList.add('dark')
|
||||||
|
localStorage.setItem('aep_theme', 'dark')
|
||||||
|
} else {
|
||||||
|
document.documentElement.classList.remove('dark')
|
||||||
|
localStorage.setItem('aep_theme', 'light')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Barre de recherche header mobile ─────────────────────────────────────
|
||||||
|
const headerSearch = ref((route.query.q as string) ?? '')
|
||||||
|
|
||||||
|
// Sync depuis URL quand la route change
|
||||||
|
watch(() => route.query.q, (v) => {
|
||||||
|
headerSearch.value = (v as string) ?? ''
|
||||||
|
})
|
||||||
|
|
||||||
|
function onHeaderSearch() {
|
||||||
|
const q = headerSearch.value.trim()
|
||||||
|
if (route.path === '/') {
|
||||||
|
router.replace({ query: q ? { ...route.query, q } : { ...route.query, q: undefined } })
|
||||||
|
} else if (q) {
|
||||||
|
router.push({ path: '/', query: { q } })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearHeaderSearch() {
|
||||||
|
headerSearch.value = ''
|
||||||
|
if (route.path === '/') {
|
||||||
|
const q = { ...route.query }
|
||||||
|
delete q.q
|
||||||
|
router.replace({ query: Object.keys(q).length ? q : undefined })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Fiche aléatoire ───────────────────────────────────────────────────────
|
||||||
|
function goRandom() {
|
||||||
|
router.push({ path: '/', query: { random: '1' } })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* ── Onglets header desktop ───────────────────────────────────────────── */
|
||||||
|
.nav-tab {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 6px 16px 8px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--nav-text-muted);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: color 0.15s;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-tab:hover {
|
||||||
|
color: var(--nav-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-tab--active {
|
||||||
|
color: var(--nav-text);
|
||||||
|
border-bottom-color: var(--nav-primary-solid);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-tab-badge {
|
||||||
|
font-size: 0.6rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--nav-text-muted);
|
||||||
|
opacity: 0.65;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
110
assets/css/main.css
Normal file
110
assets/css/main.css
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
/* ── Palette NAV V2 — Sobre institutionnel ─────────────────────────────── */
|
||||||
|
:root {
|
||||||
|
/* Fonds */
|
||||||
|
--nav-bg: #f8f6f1;
|
||||||
|
--nav-bg-alt: #eee9df;
|
||||||
|
--nav-surface: #ffffff;
|
||||||
|
|
||||||
|
/* Bleu nuit — 60% d'opacité sur les surfaces */
|
||||||
|
--nav-primary-raw: 26, 34, 56;
|
||||||
|
--nav-primary: rgba(26, 34, 56, 0.6);
|
||||||
|
--nav-primary-solid: #1a2238;
|
||||||
|
|
||||||
|
/* Accent safran */
|
||||||
|
--nav-accent: #f5b342;
|
||||||
|
--nav-accent-soft: rgba(245, 179, 66, 0.85);
|
||||||
|
|
||||||
|
/* Texte */
|
||||||
|
--nav-text: #1a2238;
|
||||||
|
--nav-text-muted: rgba(26, 34, 56, 0.55);
|
||||||
|
--nav-text-on-primary: #f8f6f1;
|
||||||
|
|
||||||
|
/* Typographie */
|
||||||
|
--nav-font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
body {
|
||||||
|
background-color: var(--nav-bg);
|
||||||
|
color: var(--nav-text);
|
||||||
|
font-family: var(--nav-font);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Leaflet overrides ──────────────────────────────────────────────────── */
|
||||||
|
.leaflet-container {
|
||||||
|
background: var(--nav-bg-alt);
|
||||||
|
font-family: var(--nav-font);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Clusters markercluster */
|
||||||
|
.marker-cluster-small,
|
||||||
|
.marker-cluster-medium,
|
||||||
|
.marker-cluster-large {
|
||||||
|
background-color: rgba(26, 34, 56, 0.25) !important;
|
||||||
|
}
|
||||||
|
.marker-cluster-small div,
|
||||||
|
.marker-cluster-medium div,
|
||||||
|
.marker-cluster-large div {
|
||||||
|
background-color: var(--nav-primary) !important;
|
||||||
|
color: var(--nav-text-on-primary) !important;
|
||||||
|
font-family: var(--nav-font);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Focus ring accessibilité */
|
||||||
|
*:focus-visible {
|
||||||
|
outline: 2px solid var(--nav-accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Dark mode ──────────────────────────────────────────────────────────── */
|
||||||
|
.dark {
|
||||||
|
--nav-bg: #111520;
|
||||||
|
--nav-bg-alt: #1e2538;
|
||||||
|
--nav-surface: #1a2238;
|
||||||
|
|
||||||
|
--nav-primary-raw: 200, 210, 240;
|
||||||
|
--nav-primary: rgba(200, 210, 240, 0.15);
|
||||||
|
--nav-primary-solid: #c8d2f0;
|
||||||
|
|
||||||
|
--nav-accent: #f5b342;
|
||||||
|
--nav-accent-soft: rgba(245, 179, 66, 0.85);
|
||||||
|
|
||||||
|
--nav-text: #e8ecf5;
|
||||||
|
--nav-text-muted: rgba(200, 210, 240, 0.55);
|
||||||
|
--nav-text-on-primary: #111520;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark body {
|
||||||
|
background-color: var(--nav-bg);
|
||||||
|
color: var(--nav-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Leaflet overrides dark */
|
||||||
|
.dark .leaflet-container {
|
||||||
|
background: var(--nav-bg-alt);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .leaflet-control-layers {
|
||||||
|
background: var(--nav-surface);
|
||||||
|
color: var(--nav-text);
|
||||||
|
border-color: var(--nav-bg-alt);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .leaflet-control-layers label {
|
||||||
|
color: var(--nav-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .leaflet-popup-content-wrapper {
|
||||||
|
background: var(--nav-surface);
|
||||||
|
color: var(--nav-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .leaflet-popup-tip {
|
||||||
|
background: var(--nav-surface);
|
||||||
|
}
|
||||||
587
components/BandeauBas.vue
Normal file
587
components/BandeauBas.vue
Normal file
@@ -0,0 +1,587 @@
|
|||||||
|
<template>
|
||||||
|
<!-- ═══════════════════════════════════════ BANDEAU BAS ═══════════════════ -->
|
||||||
|
<!-- DESKTOP uniquement (≥1024px) — mobile a le FAB séparé -->
|
||||||
|
<footer
|
||||||
|
v-if="!isMobile"
|
||||||
|
ref="bandeauEl"
|
||||||
|
class="bandeau-bas shrink-0"
|
||||||
|
:class="{ 'bandeau-collapsed': isCollapsed }"
|
||||||
|
aria-label="Informations projet AEP"
|
||||||
|
@mouseenter="onMouseEnter"
|
||||||
|
@mouseleave="onMouseLeave"
|
||||||
|
>
|
||||||
|
<!-- Contenu plein -->
|
||||||
|
<div class="bandeau-inner" :class="{ 'bandeau-inner--hidden': isCollapsed }">
|
||||||
|
|
||||||
|
<!-- ── GAUCHE : Transparence IA ──────────────────────────────────────── -->
|
||||||
|
<div class="bandeau-col">
|
||||||
|
<p class="bandeau-label">Transparence IA</p>
|
||||||
|
<template v-if="stats">
|
||||||
|
<p class="bandeau-value">
|
||||||
|
Coût IA ce mois : <strong>{{ stats.cout_mois_eur.toFixed(2) }} €</strong>
|
||||||
|
·
|
||||||
|
Tokens : <strong>{{ stats.tokens_mois.toLocaleString('fr-FR') }}</strong>
|
||||||
|
</p>
|
||||||
|
<!-- Jauge -->
|
||||||
|
<div class="jauge-track" aria-label="Budget IA consommé" role="progressbar" :aria-valuenow="jaugePct" aria-valuemin="0" aria-valuemax="100">
|
||||||
|
<div class="jauge-fill" :style="{ width: jaugePct + '%' }" />
|
||||||
|
</div>
|
||||||
|
<p class="bandeau-sub">
|
||||||
|
{{ stats.requetes_mois }} requête{{ stats.requetes_mois !== 1 ? 's' : '' }} ce mois
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="loading">
|
||||||
|
<p class="bandeau-sub">Chargement…</p>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<p class="bandeau-sub">Données indisponibles</p>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── MILIEU : CTA Soutien ──────────────────────────────────────────── -->
|
||||||
|
<div class="bandeau-col bandeau-col--center">
|
||||||
|
<div class="soutenir-wrap">
|
||||||
|
<button
|
||||||
|
class="btn-soutenir"
|
||||||
|
type="button"
|
||||||
|
@click="modalOpen = true"
|
||||||
|
@mouseenter="tooltipVisible = true"
|
||||||
|
@mouseleave="tooltipVisible = false"
|
||||||
|
@focus="tooltipVisible = true"
|
||||||
|
@blur="tooltipVisible = false"
|
||||||
|
aria-label="Soutenir le projet AEP sur Liberapay"
|
||||||
|
>
|
||||||
|
Soutenir le projet
|
||||||
|
</button>
|
||||||
|
<!-- Tooltip au hover -->
|
||||||
|
<div v-if="tooltipVisible" class="soutenir-tooltip" role="tooltip">
|
||||||
|
1 € = 30 fiches mises en ligne
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── DROITE : Compteurs semaine ────────────────────────────────────── -->
|
||||||
|
<div class="bandeau-col bandeau-col--right">
|
||||||
|
<p class="bandeau-label">Cette semaine</p>
|
||||||
|
<template v-if="stats">
|
||||||
|
<p class="bandeau-value">
|
||||||
|
{{ stats.fiches_semaine }} fiche{{ stats.fiches_semaine !== 1 ? 's' : '' }} ajoutée{{ stats.fiches_semaine !== 1 ? 's' : '' }}
|
||||||
|
</p>
|
||||||
|
<p class="bandeau-sub">
|
||||||
|
{{ stats.requetes_chatbot_semaine }} requête{{ stats.requetes_chatbot_semaine !== 1 ? 's' : '' }} chatbot
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<p class="bandeau-sub">—</p>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Barre fine (état collapsed) -->
|
||||||
|
<div class="bandeau-thin" :class="{ 'bandeau-thin--visible': isCollapsed }">
|
||||||
|
<span class="bandeau-thin-label">AEP · Transparence IA</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── MODAL Liberapay ───────────────────────────────────────────────── -->
|
||||||
|
<Teleport to="body">
|
||||||
|
<Transition name="backdrop">
|
||||||
|
<div
|
||||||
|
v-if="modalOpen"
|
||||||
|
class="modal-backdrop"
|
||||||
|
@click="modalOpen = false"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</Transition>
|
||||||
|
<Transition name="modal">
|
||||||
|
<div
|
||||||
|
v-if="modalOpen"
|
||||||
|
class="modal-box"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="Soutenir AEP sur Liberapay"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="modal-close"
|
||||||
|
type="button"
|
||||||
|
@click="modalOpen = false"
|
||||||
|
aria-label="Fermer"
|
||||||
|
>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" aria-hidden="true">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<h2 class="modal-title">Soutenir AEP</h2>
|
||||||
|
<p class="modal-desc">
|
||||||
|
AEP est un outil libre, sans publicité, financé par les dons.
|
||||||
|
1 € finance environ 30 fiches mises en ligne.
|
||||||
|
</p>
|
||||||
|
<div class="modal-widget">
|
||||||
|
<iframe
|
||||||
|
src="https://liberapay.com/trans-former.fr/widgets/button.html"
|
||||||
|
width="95"
|
||||||
|
height="22"
|
||||||
|
style="border: 0;"
|
||||||
|
title="Faire un don sur Liberapay"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
href="https://liberapay.com/trans-former.fr/donate"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="modal-link"
|
||||||
|
>
|
||||||
|
Faire un don sur Liberapay →
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</Teleport>
|
||||||
|
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<!-- ═══════════════════════════════════════ FAB MOBILE (< 1024px) ════════ -->
|
||||||
|
<div v-else>
|
||||||
|
<!-- FAB soutenir (à gauche du chatbot) -->
|
||||||
|
<button
|
||||||
|
class="fab-soutenir"
|
||||||
|
type="button"
|
||||||
|
@click="fabSheetOpen = true"
|
||||||
|
aria-label="Soutenir le projet AEP"
|
||||||
|
>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Bottom sheet FAB -->
|
||||||
|
<Teleport to="body">
|
||||||
|
<Transition name="backdrop">
|
||||||
|
<div
|
||||||
|
v-if="fabSheetOpen"
|
||||||
|
class="fixed inset-0 z-[1020]"
|
||||||
|
style="background: rgba(26,34,56,0.5);"
|
||||||
|
@click="fabSheetOpen = false"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</Transition>
|
||||||
|
<Transition name="sheet">
|
||||||
|
<div
|
||||||
|
v-if="fabSheetOpen"
|
||||||
|
class="fab-sheet"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="Soutenir AEP"
|
||||||
|
>
|
||||||
|
<!-- Poignée -->
|
||||||
|
<div class="flex justify-center pt-3 pb-1">
|
||||||
|
<div class="rounded-full" style="width: 36px; height: 4px; background: var(--nav-bg-alt);" />
|
||||||
|
</div>
|
||||||
|
<div class="px-5 pb-6">
|
||||||
|
<h2 class="text-base font-bold mb-2" style="color: var(--nav-text);">Soutenir AEP</h2>
|
||||||
|
<template v-if="stats">
|
||||||
|
<p class="text-sm mb-1" style="color: var(--nav-text-muted);">
|
||||||
|
Coût IA ce mois : <strong>{{ stats.cout_mois_eur.toFixed(2) }} €</strong>
|
||||||
|
· Tokens : {{ stats.tokens_mois.toLocaleString('fr-FR') }}
|
||||||
|
</p>
|
||||||
|
<p class="text-sm mb-3" style="color: var(--nav-text-muted);">
|
||||||
|
{{ stats.fiches_semaine }} fiche{{ stats.fiches_semaine !== 1 ? 's' : '' }} ajoutée{{ stats.fiches_semaine !== 1 ? 's' : '' }} cette semaine
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
<p class="text-sm mb-4" style="color: var(--nav-text-muted); line-height: 1.5;">
|
||||||
|
1 € = 30 fiches mises en ligne. AEP est libre, sans pub, financé par les dons.
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
href="https://liberapay.com/trans-former.fr/donate"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="block w-full text-center py-3 rounded-xl font-semibold text-sm"
|
||||||
|
style="background: var(--nav-primary); color: var(--nav-text-on-primary); text-decoration: none;"
|
||||||
|
@click="fabSheetOpen = false"
|
||||||
|
>
|
||||||
|
Soutenir sur Liberapay →
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</Teleport>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
interface Stats {
|
||||||
|
cout_mois_eur: number
|
||||||
|
budget_mois: number
|
||||||
|
tokens_mois: number
|
||||||
|
co2_kg: number
|
||||||
|
requetes_mois: number
|
||||||
|
fiches_semaine: number
|
||||||
|
requetes_chatbot_semaine: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = ref<Stats | null>(null)
|
||||||
|
const loading = ref(true)
|
||||||
|
const modalOpen = ref(false)
|
||||||
|
const fabSheetOpen = ref(false)
|
||||||
|
const tooltipVisible = ref(false)
|
||||||
|
|
||||||
|
// Desktop — replié par défaut, déploie au hover, replie immédiatement à la sortie
|
||||||
|
const bandeauEl = ref<HTMLElement | null>(null)
|
||||||
|
const isCollapsed = ref(true) // replié par défaut
|
||||||
|
|
||||||
|
const REFRESH_MS = 5 * 60 * 1000 // 5 minutes
|
||||||
|
|
||||||
|
// Détection mobile côté client
|
||||||
|
const isMobile = ref(false)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
isMobile.value = window.innerWidth < 1024
|
||||||
|
|
||||||
|
const handleResize = () => {
|
||||||
|
isMobile.value = window.innerWidth < 1024
|
||||||
|
}
|
||||||
|
window.addEventListener('resize', handleResize)
|
||||||
|
onUnmounted(() => window.removeEventListener('resize', handleResize))
|
||||||
|
|
||||||
|
fetchStats()
|
||||||
|
const interval = setInterval(fetchStats, REFRESH_MS)
|
||||||
|
onUnmounted(() => clearInterval(interval))
|
||||||
|
})
|
||||||
|
|
||||||
|
function onMouseEnter() {
|
||||||
|
isCollapsed.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function onMouseLeave() {
|
||||||
|
// Repli immédiat — pas de timer
|
||||||
|
isCollapsed.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchStats() {
|
||||||
|
try {
|
||||||
|
const res = await $fetch<Stats>('/api/stats')
|
||||||
|
stats.value = res
|
||||||
|
} catch {
|
||||||
|
stats.value = null
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const jaugePct = computed(() => {
|
||||||
|
if (!stats.value) return 0
|
||||||
|
return Math.min(100, Math.round((stats.value.cout_mois_eur / stats.value.budget_mois) * 100))
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* ── Bandeau bas ─────────────────────────────────────────────────────────── */
|
||||||
|
.bandeau-bas {
|
||||||
|
background: rgba(26, 34, 56, 0.7); /* opacité 70% */
|
||||||
|
color: var(--nav-text-on-primary);
|
||||||
|
font-family: var(--nav-font, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif);
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
transition: min-height 0.25s ease;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bandeau-collapsed {
|
||||||
|
min-height: 32px !important;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bandeau-inner {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 10px 20px;
|
||||||
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
min-height: 64px;
|
||||||
|
transition: opacity 0.2s ease, max-height 0.3s ease;
|
||||||
|
max-height: 200px;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bandeau-inner--hidden {
|
||||||
|
opacity: 0;
|
||||||
|
max-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
padding-top: 0;
|
||||||
|
padding-bottom: 0;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Barre fine (collapsed) */
|
||||||
|
.bandeau-thin {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bandeau-thin--visible {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bandeau-thin-label {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Colonnes ────────────────────────────────────────────────────────────── */
|
||||||
|
.bandeau-col {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bandeau-col--center {
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding-top: 8px; /* décaler légèrement vers le bas pour mieux centrer dans la hauteur */
|
||||||
|
}
|
||||||
|
|
||||||
|
.bandeau-col--right {
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Typo ────────────────────────────────────────────────────────────────── */
|
||||||
|
.bandeau-label {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.07em;
|
||||||
|
opacity: 0.65;
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bandeau-value {
|
||||||
|
font-size: 0.775rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--nav-text-on-primary);
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bandeau-value strong {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bandeau-sub {
|
||||||
|
font-size: 0.68rem;
|
||||||
|
opacity: 0.7;
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Jauge budget ────────────────────────────────────────────────────────── */
|
||||||
|
.jauge-track {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 180px;
|
||||||
|
height: 4px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: rgba(255, 255, 255, 0.18);
|
||||||
|
overflow: hidden;
|
||||||
|
margin: 3px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jauge-fill {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: var(--nav-accent);
|
||||||
|
transition: width 0.6s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Bouton soutenir + tooltip ───────────────────────────────────────────── */
|
||||||
|
.soutenir-wrap {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-soutenir {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 7px 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: none;
|
||||||
|
background: var(--nav-accent);
|
||||||
|
color: var(--nav-text);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 700;
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: opacity 0.15s, transform 0.1s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-soutenir:hover { opacity: 0.88; transform: translateY(-1px); }
|
||||||
|
.btn-soutenir:active { opacity: 1; transform: translateY(0); }
|
||||||
|
|
||||||
|
.soutenir-tooltip {
|
||||||
|
position: absolute;
|
||||||
|
bottom: calc(100% + 8px);
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
background: var(--nav-primary-solid, #1a2238);
|
||||||
|
color: var(--nav-text-on-primary);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 5px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
white-space: nowrap;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.soutenir-tooltip::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
border: 5px solid transparent;
|
||||||
|
border-top-color: var(--nav-primary-solid, #1a2238);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── FAB mobile soutenir ─────────────────────────────────────────────────── */
|
||||||
|
.fab-soutenir {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 68px; /* au-dessus du FAB chatbot à 24px du bas + 48px de hauteur */
|
||||||
|
left: 16px;
|
||||||
|
z-index: 1000;
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: none;
|
||||||
|
background: var(--nav-accent);
|
||||||
|
color: var(--nav-text);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-shadow: 0 4px 16px rgba(26,34,56,0.25);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.15s, transform 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fab-soutenir:hover { opacity: 0.88; transform: translateY(-1px); }
|
||||||
|
|
||||||
|
/* ── Bottom sheet FAB ────────────────────────────────────────────────────── */
|
||||||
|
.fab-sheet {
|
||||||
|
position: fixed;
|
||||||
|
inset-x: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 1021;
|
||||||
|
background: var(--nav-surface);
|
||||||
|
border-radius: 16px 16px 0 0;
|
||||||
|
box-shadow: 0 -4px 32px rgba(26,34,56,0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Modal ───────────────────────────────────────────────────────────────── */
|
||||||
|
.modal-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 2000;
|
||||||
|
background: rgba(26, 34, 56, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-box {
|
||||||
|
position: fixed;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
z-index: 2001;
|
||||||
|
background: var(--nav-surface, #ffffff);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 28px 24px 24px;
|
||||||
|
width: min(380px, 90vw);
|
||||||
|
box-shadow: 0 8px 40px rgba(26, 34, 56, 0.22);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close {
|
||||||
|
position: absolute;
|
||||||
|
top: 14px;
|
||||||
|
right: 14px;
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: none;
|
||||||
|
background: var(--nav-bg-alt, #eee9df);
|
||||||
|
color: var(--nav-text-muted, rgba(26,34,56,0.55));
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
.modal-close:hover { background: var(--nav-bg, #f8f6f1); }
|
||||||
|
|
||||||
|
.modal-title {
|
||||||
|
font-size: 1.05rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--nav-text, #1a2238);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-desc {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--nav-text-muted, rgba(26,34,56,0.55));
|
||||||
|
line-height: 1.6;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-widget {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 12px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-link {
|
||||||
|
display: block;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--nav-primary-solid, #1a2238);
|
||||||
|
text-decoration: underline;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
.modal-link:hover { opacity: 0.7; }
|
||||||
|
|
||||||
|
/* ── Transitions ─────────────────────────────────────────────────────────── */
|
||||||
|
.backdrop-enter-active, .backdrop-leave-active { transition: opacity 0.2s ease; }
|
||||||
|
.backdrop-enter-from, .backdrop-leave-to { opacity: 0; }
|
||||||
|
|
||||||
|
.modal-enter-active, .modal-leave-active { transition: opacity 0.2s ease, transform 0.2s ease; }
|
||||||
|
.modal-enter-from, .modal-leave-to { opacity: 0; transform: translate(-50%, -48%); }
|
||||||
|
|
||||||
|
.sheet-enter-active, .sheet-leave-active { transition: transform 0.3s cubic-bezier(0.32, 0.72, 0, 1); }
|
||||||
|
.sheet-enter-from, .sheet-leave-to { transform: translateY(100%); }
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.btn-soutenir { transition: none; }
|
||||||
|
.jauge-fill { transition: none; }
|
||||||
|
.modal-enter-active, .modal-leave-active { transition: none; }
|
||||||
|
.backdrop-enter-active, .backdrop-leave-active { transition: none; }
|
||||||
|
.sheet-enter-active, .sheet-leave-active { transition: none; }
|
||||||
|
.bandeau-bas { transition: none; }
|
||||||
|
.bandeau-inner { transition: none; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
382
components/ChatbotPlaceholder.vue
Normal file
382
components/ChatbotPlaceholder.vue
Normal file
@@ -0,0 +1,382 @@
|
|||||||
|
<template>
|
||||||
|
<!-- Zone chatbot desktop — sous la carte, expand/collapse -->
|
||||||
|
<div
|
||||||
|
class="chatbot-placeholder shrink-0"
|
||||||
|
:class="{ expanded: isExpanded }"
|
||||||
|
style="border-top: 1px solid var(--nav-bg-alt); background: var(--nav-bg);"
|
||||||
|
>
|
||||||
|
<!-- ── HEADER (toujours visible, cliquable) ── -->
|
||||||
|
<div
|
||||||
|
class="chatbot-header"
|
||||||
|
@click="toggleExpand"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
:aria-expanded="isExpanded"
|
||||||
|
aria-label="Ouvrir l'assistant chatbot"
|
||||||
|
@keydown.enter="toggleExpand"
|
||||||
|
@keydown.space.prevent="toggleExpand"
|
||||||
|
>
|
||||||
|
<!-- Icône chatbot -->
|
||||||
|
<div
|
||||||
|
class="shrink-0 w-7 h-7 rounded-full flex items-center justify-center"
|
||||||
|
style="background: var(--nav-primary);"
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="color: var(--nav-text-on-primary);">
|
||||||
|
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span class="flex-1 text-sm" style="color: var(--nav-text-muted);">
|
||||||
|
{{ isExpanded ? 'Chatbot AEP' : 'Pose une question sur le réseau…' }}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<!-- Chevron -->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="chatbot-chevron"
|
||||||
|
:aria-label="isExpanded ? 'Replier le chatbot' : 'Ouvrir le chatbot'"
|
||||||
|
@click.stop="toggleExpand"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="14" height="14" viewBox="0 0 24 24" fill="none"
|
||||||
|
stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"
|
||||||
|
:style="{ transform: isExpanded ? 'rotate(180deg)' : 'rotate(0deg)', transition: 'transform 0.3s ease' }"
|
||||||
|
>
|
||||||
|
<polyline points="18 15 12 9 6 15"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── ZONE ÉTENDUE ── -->
|
||||||
|
<div class="chatbot-body">
|
||||||
|
<div class="chatbot-body-inner" ref="messagesContainer">
|
||||||
|
<!-- Onboarding -->
|
||||||
|
<div v-if="messages.length === 0" class="onboarding-bubble">
|
||||||
|
<p>Ce chatbot fonctionne sur un serveur européen souverain
|
||||||
|
(Mistral FR, zéro rétention), conçu sobre en énergie.</p>
|
||||||
|
<p>Pour m'aider à te répondre efficacement,
|
||||||
|
formule ta requête ainsi :</p>
|
||||||
|
<ul>
|
||||||
|
<li>• Besoin : [ce que tu cherches]</li>
|
||||||
|
<li>• Thématique : [juridique / technique / économique / ...]</li>
|
||||||
|
<li>• Lieu : [région ou ville]</li>
|
||||||
|
</ul>
|
||||||
|
<p class="example">Exemple : "Je suis salarié d'agence, litige avec mon
|
||||||
|
employeur, besoin conseil juridique droit du travail,
|
||||||
|
Île-de-France."</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Messages -->
|
||||||
|
<template v-for="(msg, i) in messages" :key="i">
|
||||||
|
<div v-if="msg.role === 'user'" class="user-bubble">{{ msg.content }}</div>
|
||||||
|
<div v-else class="assistant-bubble">
|
||||||
|
<p>{{ msg.content }}</p>
|
||||||
|
<div v-if="msg.fiches && msg.fiches.length > 0" class="fiches-list">
|
||||||
|
<p class="fiches-title">Fiches recommandées :</p>
|
||||||
|
<a
|
||||||
|
v-for="fiche in msg.fiches"
|
||||||
|
:key="fiche.id"
|
||||||
|
:href="`/fiche/${fiche.id}`"
|
||||||
|
class="fiche-card"
|
||||||
|
>
|
||||||
|
<span class="fiche-nom">{{ fiche.nom }}</span>
|
||||||
|
<span v-if="fiche.explication" class="fiche-expl">{{ fiche.explication }}</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Chargement -->
|
||||||
|
<div v-if="loading" class="assistant-bubble loading-bubble">
|
||||||
|
<span class="dot" /><span class="dot" /><span class="dot" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Erreur -->
|
||||||
|
<div v-if="errorMsg" class="error-bubble">{{ errorMsg }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Input -->
|
||||||
|
<div class="chatbot-input-row" style="border-top: 1px solid var(--nav-bg-alt);">
|
||||||
|
<input
|
||||||
|
v-model="inputText"
|
||||||
|
type="text"
|
||||||
|
:disabled="loading"
|
||||||
|
placeholder="Pose ta question…"
|
||||||
|
class="chatbot-input"
|
||||||
|
@keydown.enter.prevent="sendMessage"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
:disabled="loading || !inputText.trim()"
|
||||||
|
class="chatbot-send"
|
||||||
|
aria-label="Envoyer"
|
||||||
|
@click="sendMessage"
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="color: var(--nav-text-on-primary);">
|
||||||
|
<line x1="22" y1="2" x2="11" y2="13"/>
|
||||||
|
<polygon points="22 2 15 22 11 13 2 9 22 2"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
interface FicheReco {
|
||||||
|
id: number | string
|
||||||
|
nom: string
|
||||||
|
explication?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChatMessage {
|
||||||
|
role: 'user' | 'assistant'
|
||||||
|
content: string
|
||||||
|
fiches?: FicheReco[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'highlightOrgs': [ids: (number | string)[]]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const isExpanded = ref(false)
|
||||||
|
const messages = ref<ChatMessage[]>([])
|
||||||
|
const inputText = ref('')
|
||||||
|
const loading = ref(false)
|
||||||
|
const errorMsg = ref('')
|
||||||
|
const messagesContainer = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
|
function toggleExpand() {
|
||||||
|
isExpanded.value = !isExpanded.value
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendMessage() {
|
||||||
|
const question = inputText.value.trim()
|
||||||
|
if (!question || loading.value) return
|
||||||
|
|
||||||
|
inputText.value = ''
|
||||||
|
errorMsg.value = ''
|
||||||
|
messages.value.push({ role: 'user', content: question })
|
||||||
|
loading.value = true
|
||||||
|
|
||||||
|
await nextTick()
|
||||||
|
scrollToBottom()
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await $fetch<{
|
||||||
|
reponse_texte: string
|
||||||
|
fiches_recommandees: { id: number | string; nom: string; explication: string }[]
|
||||||
|
}>('/api/chatbot', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { question },
|
||||||
|
})
|
||||||
|
|
||||||
|
const assistantMsg: ChatMessage = {
|
||||||
|
role: 'assistant',
|
||||||
|
content: res.reponse_texte,
|
||||||
|
fiches: res.fiches_recommandees || [],
|
||||||
|
}
|
||||||
|
messages.value.push(assistantMsg)
|
||||||
|
|
||||||
|
if (assistantMsg.fiches && assistantMsg.fiches.length > 0) {
|
||||||
|
emit('highlightOrgs', assistantMsg.fiches.map((f) => f.id))
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
const status = e?.statusCode ?? e?.status
|
||||||
|
if (status === 429) {
|
||||||
|
errorMsg.value = 'Limite de 10 questions par jour atteinte. Reviens demain.'
|
||||||
|
} else if (status === 503) {
|
||||||
|
errorMsg.value = 'Le budget IA mensuel est épuisé. Soutiens NAV sur Liberapay pour continuer.'
|
||||||
|
} else {
|
||||||
|
errorMsg.value = 'Une erreur est survenue. Réessaie dans quelques instants.'
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
await nextTick()
|
||||||
|
scrollToBottom()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollToBottom() {
|
||||||
|
if (messagesContainer.value) {
|
||||||
|
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.chatbot-placeholder {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: max-height 0.3s ease;
|
||||||
|
max-height: 56px;
|
||||||
|
}
|
||||||
|
.chatbot-placeholder.expanded {
|
||||||
|
max-height: 55vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatbot-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
min-height: 56px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.chatbot-header:hover { background: var(--nav-surface); }
|
||||||
|
|
||||||
|
.chatbot-chevron {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--nav-text-muted);
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 4px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.15s, background 0.15s;
|
||||||
|
}
|
||||||
|
.chatbot-chevron:hover {
|
||||||
|
color: var(--nav-text);
|
||||||
|
background: var(--nav-bg-alt);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatbot-body {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
border-top: 1px solid var(--nav-bg-alt);
|
||||||
|
display: none;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.chatbot-placeholder.expanded .chatbot-body {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatbot-body-inner {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 12px 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatbot-input-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.chatbot-input {
|
||||||
|
flex: 1;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid var(--nav-bg-alt);
|
||||||
|
background: var(--nav-surface);
|
||||||
|
color: var(--nav-text);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-family: var(--nav-font);
|
||||||
|
}
|
||||||
|
.chatbot-send {
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--nav-primary);
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
.chatbot-send:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||||
|
|
||||||
|
/* Messages */
|
||||||
|
.onboarding-bubble {
|
||||||
|
background: var(--nav-bg-alt);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 12px;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--nav-text-muted);
|
||||||
|
white-space: pre-line;
|
||||||
|
}
|
||||||
|
.onboarding-bubble p { margin-bottom: 8px; }
|
||||||
|
.onboarding-bubble ul { margin: 6px 0; padding: 0; list-style: none; }
|
||||||
|
.onboarding-bubble li { margin-bottom: 2px; }
|
||||||
|
.onboarding-bubble .example { font-style: italic; opacity: 0.8; font-size: 0.75rem; margin-top: 8px; }
|
||||||
|
|
||||||
|
.user-bubble {
|
||||||
|
align-self: flex-end;
|
||||||
|
max-width: 80%;
|
||||||
|
background: var(--nav-primary);
|
||||||
|
color: var(--nav-text-on-primary);
|
||||||
|
border-radius: 12px 12px 4px 12px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-bubble {
|
||||||
|
align-self: flex-start;
|
||||||
|
max-width: 92%;
|
||||||
|
background: var(--nav-surface);
|
||||||
|
border: 1px solid var(--nav-bg-alt);
|
||||||
|
border-radius: 12px 12px 12px 4px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--nav-text);
|
||||||
|
}
|
||||||
|
.assistant-bubble p { margin: 0; }
|
||||||
|
|
||||||
|
.fiches-list { margin-top: 8px; display: flex; flex-direction: column; gap: 4px; }
|
||||||
|
.fiches-title { font-size: 0.7rem; font-weight: 600; color: var(--nav-text-muted); text-transform: uppercase; letter-spacing: 0.04em; margin-bottom: 4px; }
|
||||||
|
.fiche-card {
|
||||||
|
display: block;
|
||||||
|
background: var(--nav-bg);
|
||||||
|
border: 1px solid var(--nav-bg-alt);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
.fiche-card:hover { border-color: var(--nav-primary); }
|
||||||
|
.fiche-nom { display: block; font-size: 0.775rem; font-weight: 600; color: var(--nav-text); }
|
||||||
|
.fiche-expl { display: block; font-size: 0.72rem; color: var(--nav-text-muted); margin-top: 1px; }
|
||||||
|
|
||||||
|
.loading-bubble { display: flex; gap: 4px; padding: 10px 14px; }
|
||||||
|
.dot {
|
||||||
|
width: 6px; height: 6px;
|
||||||
|
background: var(--nav-text-muted);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: blink 1.2s infinite ease-in-out;
|
||||||
|
}
|
||||||
|
.dot:nth-child(2) { animation-delay: 0.2s; }
|
||||||
|
.dot:nth-child(3) { animation-delay: 0.4s; }
|
||||||
|
@keyframes blink {
|
||||||
|
0%, 80%, 100% { opacity: 0.3; transform: scale(0.85); }
|
||||||
|
40% { opacity: 1; transform: scale(1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-bubble {
|
||||||
|
background: rgba(220, 50, 50, 0.07);
|
||||||
|
border: 1px solid rgba(220, 50, 50, 0.18);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: #c0392b;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.chatbot-placeholder { transition: none; }
|
||||||
|
.dot { animation: none; opacity: 0.5; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
406
components/ChatbotSheet.vue
Normal file
406
components/ChatbotSheet.vue
Normal file
@@ -0,0 +1,406 @@
|
|||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<!-- Backdrop -->
|
||||||
|
<transition name="backdrop">
|
||||||
|
<div
|
||||||
|
v-if="modelValue"
|
||||||
|
class="fixed inset-0 z-[1010]"
|
||||||
|
style="background: rgba(26,34,56,0.5);"
|
||||||
|
@click="emit('update:modelValue', false)"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</transition>
|
||||||
|
|
||||||
|
<!-- Bottom sheet plein écran -->
|
||||||
|
<transition name="sheet">
|
||||||
|
<div
|
||||||
|
v-if="modelValue"
|
||||||
|
class="fixed inset-x-0 bottom-0 z-[1011] flex flex-col"
|
||||||
|
style="
|
||||||
|
background: var(--nav-surface);
|
||||||
|
height: 100dvh;
|
||||||
|
max-height: 100dvh;
|
||||||
|
border-radius: 0;
|
||||||
|
box-shadow: 0 -4px 32px rgba(26,34,56,0.18);
|
||||||
|
"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="Assistant AEP"
|
||||||
|
>
|
||||||
|
<!-- Poignée visuelle -->
|
||||||
|
<div class="flex justify-center pt-3 pb-1 shrink-0">
|
||||||
|
<div
|
||||||
|
class="rounded-full"
|
||||||
|
style="width: 36px; height: 4px; background: var(--nav-bg-alt);"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-between px-4 py-3 shrink-0 border-b"
|
||||||
|
style="border-color: var(--nav-bg-alt);"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
@click="emit('update:modelValue', false)"
|
||||||
|
class="flex items-center gap-2 text-sm font-medium transition-opacity hover:opacity-70"
|
||||||
|
style="color: var(--nav-text-muted);"
|
||||||
|
aria-label="Retour"
|
||||||
|
>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<polyline points="15 18 9 12 15 6"/>
|
||||||
|
</svg>
|
||||||
|
Retour
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div
|
||||||
|
class="w-7 h-7 rounded-full flex items-center justify-center shrink-0"
|
||||||
|
style="background: var(--nav-primary);"
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="color: var(--nav-text-on-primary);">
|
||||||
|
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<span class="font-bold text-sm" style="color: var(--nav-text);">Chatbot</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Zone conversation -->
|
||||||
|
<div ref="messagesContainer" class="flex-1 overflow-y-auto px-4 py-4 flex flex-col gap-3">
|
||||||
|
<!-- Message onboarding (avant la première question) -->
|
||||||
|
<div v-if="messages.length === 0" class="onboarding-bubble">
|
||||||
|
<p>Ce chatbot fonctionne sur un serveur européen souverain
|
||||||
|
(Mistral FR, zéro rétention), conçu sobre en énergie.</p>
|
||||||
|
<p>Pour m'aider à te répondre efficacement,
|
||||||
|
formule ta requête ainsi :</p>
|
||||||
|
<ul>
|
||||||
|
<li>• Besoin : [ce que tu cherches]</li>
|
||||||
|
<li>• Thématique : [juridique / technique / économique / ...]</li>
|
||||||
|
<li>• Lieu : [région ou ville]</li>
|
||||||
|
</ul>
|
||||||
|
<p class="example">Exemple : "Je suis salarié d'agence, litige avec mon
|
||||||
|
employeur, besoin conseil juridique droit du travail,
|
||||||
|
Île-de-France."</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Messages -->
|
||||||
|
<template v-for="(msg, i) in messages" :key="i">
|
||||||
|
<!-- Message utilisateur -->
|
||||||
|
<div v-if="msg.role === 'user'" class="user-bubble">
|
||||||
|
{{ msg.content }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Message assistant -->
|
||||||
|
<div v-else class="assistant-bubble">
|
||||||
|
<p>{{ msg.content }}</p>
|
||||||
|
|
||||||
|
<!-- Fiches recommandées -->
|
||||||
|
<div v-if="msg.fiches && msg.fiches.length > 0" class="fiches-list">
|
||||||
|
<p class="fiches-title">Fiches recommandées :</p>
|
||||||
|
<a
|
||||||
|
v-for="fiche in msg.fiches"
|
||||||
|
:key="fiche.id"
|
||||||
|
:href="`/fiche/${fiche.id}`"
|
||||||
|
class="fiche-card"
|
||||||
|
>
|
||||||
|
<span class="fiche-nom">{{ fiche.nom }}</span>
|
||||||
|
<span v-if="fiche.explication" class="fiche-expl">{{ fiche.explication }}</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Indicateur de chargement -->
|
||||||
|
<div v-if="loading" class="assistant-bubble loading-bubble">
|
||||||
|
<span class="dot" /><span class="dot" /><span class="dot" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Message d'erreur -->
|
||||||
|
<div v-if="errorMsg" class="error-bubble">
|
||||||
|
{{ errorMsg }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Input -->
|
||||||
|
<div
|
||||||
|
class="shrink-0 px-4 pt-3 border-t"
|
||||||
|
style="border-color: var(--nav-bg-alt); padding-bottom: max(1rem, env(safe-area-inset-bottom));"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
v-model="inputText"
|
||||||
|
type="text"
|
||||||
|
:disabled="loading"
|
||||||
|
placeholder="Pose ta question…"
|
||||||
|
class="flex-1 px-4 py-3 rounded-xl text-sm border"
|
||||||
|
:class="loading ? 'cursor-not-allowed' : ''"
|
||||||
|
style="
|
||||||
|
border-color: var(--nav-bg-alt);
|
||||||
|
background: var(--nav-bg);
|
||||||
|
color: var(--nav-text);
|
||||||
|
font-family: var(--nav-font);
|
||||||
|
font-size: 16px;
|
||||||
|
"
|
||||||
|
@keydown.enter.prevent="sendMessage"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
:disabled="loading || !inputText.trim()"
|
||||||
|
class="w-11 h-11 rounded-xl flex items-center justify-center shrink-0 transition-opacity"
|
||||||
|
style="background: var(--nav-primary);"
|
||||||
|
:style="{ opacity: (loading || !inputText.trim()) ? 0.4 : 1 }"
|
||||||
|
aria-label="Envoyer"
|
||||||
|
@click="sendMessage"
|
||||||
|
>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="color: var(--nav-text-on-primary);">
|
||||||
|
<line x1="22" y1="2" x2="11" y2="13"/>
|
||||||
|
<polygon points="22 2 15 22 11 13 2 9 22 2"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
interface FicheReco {
|
||||||
|
id: number | string
|
||||||
|
nom: string
|
||||||
|
explication?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChatMessage {
|
||||||
|
role: 'user' | 'assistant'
|
||||||
|
content: string
|
||||||
|
fiches?: FicheReco[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: boolean]
|
||||||
|
'highlightOrgs': [ids: (number | string)[]]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const messages = ref<ChatMessage[]>([])
|
||||||
|
const inputText = ref('')
|
||||||
|
const loading = ref(false)
|
||||||
|
const errorMsg = ref('')
|
||||||
|
const messagesContainer = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
|
watch(() => props.modelValue, (open) => {
|
||||||
|
if (typeof document === 'undefined') return
|
||||||
|
if (open) {
|
||||||
|
document.body.style.overflow = 'hidden'
|
||||||
|
document.documentElement.style.overflow = 'hidden'
|
||||||
|
} else {
|
||||||
|
document.body.style.overflow = ''
|
||||||
|
document.documentElement.style.overflow = ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (typeof document !== 'undefined') {
|
||||||
|
document.body.style.overflow = ''
|
||||||
|
document.documentElement.style.overflow = ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
async function sendMessage() {
|
||||||
|
const question = inputText.value.trim()
|
||||||
|
if (!question || loading.value) return
|
||||||
|
|
||||||
|
inputText.value = ''
|
||||||
|
errorMsg.value = ''
|
||||||
|
messages.value.push({ role: 'user', content: question })
|
||||||
|
loading.value = true
|
||||||
|
|
||||||
|
await nextTick()
|
||||||
|
scrollToBottom()
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await $fetch<{
|
||||||
|
reponse_texte: string
|
||||||
|
fiches_recommandees: { id: number | string; nom: string; explication: string }[]
|
||||||
|
}>('/api/chatbot', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { question },
|
||||||
|
})
|
||||||
|
|
||||||
|
const assistantMsg: ChatMessage = {
|
||||||
|
role: 'assistant',
|
||||||
|
content: res.reponse_texte,
|
||||||
|
fiches: res.fiches_recommandees || [],
|
||||||
|
}
|
||||||
|
messages.value.push(assistantMsg)
|
||||||
|
|
||||||
|
// Highlight carte si des fiches sont recommandées
|
||||||
|
if (assistantMsg.fiches && assistantMsg.fiches.length > 0) {
|
||||||
|
emit('highlightOrgs', assistantMsg.fiches.map((f) => f.id))
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
const status = e?.statusCode ?? e?.status
|
||||||
|
if (status === 429) {
|
||||||
|
errorMsg.value = 'Limite de 10 questions par jour atteinte. Reviens demain.'
|
||||||
|
} else if (status === 503) {
|
||||||
|
errorMsg.value = 'Le budget IA mensuel est épuisé. Soutiens NAV sur Liberapay pour continuer.'
|
||||||
|
} else {
|
||||||
|
errorMsg.value = 'Une erreur est survenue. Réessaie dans quelques instants.'
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
await nextTick()
|
||||||
|
scrollToBottom()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollToBottom() {
|
||||||
|
if (messagesContainer.value) {
|
||||||
|
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.backdrop-enter-active,
|
||||||
|
.backdrop-leave-active { transition: opacity 0.2s ease; }
|
||||||
|
.backdrop-enter-from,
|
||||||
|
.backdrop-leave-to { opacity: 0; }
|
||||||
|
|
||||||
|
.sheet-enter-active,
|
||||||
|
.sheet-leave-active { transition: transform 0.3s cubic-bezier(0.32, 0.72, 0, 1); }
|
||||||
|
.sheet-enter-from,
|
||||||
|
.sheet-leave-to { transform: translateY(100%); }
|
||||||
|
|
||||||
|
/* Onboarding */
|
||||||
|
.onboarding-bubble {
|
||||||
|
background: var(--nav-bg);
|
||||||
|
border: 1px solid var(--nav-bg-alt);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
line-height: 1.65;
|
||||||
|
color: var(--nav-text-muted);
|
||||||
|
white-space: pre-line;
|
||||||
|
}
|
||||||
|
.onboarding-bubble p { margin-bottom: 10px; }
|
||||||
|
.onboarding-bubble ul { margin: 8px 0; padding: 0; list-style: none; }
|
||||||
|
.onboarding-bubble li { margin-bottom: 4px; }
|
||||||
|
.onboarding-bubble .example {
|
||||||
|
margin-top: 12px;
|
||||||
|
font-style: italic;
|
||||||
|
opacity: 0.8;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bulles utilisateur */
|
||||||
|
.user-bubble {
|
||||||
|
align-self: flex-end;
|
||||||
|
max-width: 80%;
|
||||||
|
background: var(--nav-primary);
|
||||||
|
color: var(--nav-text-on-primary);
|
||||||
|
border-radius: 16px 16px 4px 16px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bulles assistant */
|
||||||
|
.assistant-bubble {
|
||||||
|
align-self: flex-start;
|
||||||
|
max-width: 90%;
|
||||||
|
background: var(--nav-surface);
|
||||||
|
border: 1px solid var(--nav-bg-alt);
|
||||||
|
border-radius: 16px 16px 16px 4px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--nav-text);
|
||||||
|
}
|
||||||
|
.assistant-bubble p { margin: 0; }
|
||||||
|
|
||||||
|
/* Fiches recommandées */
|
||||||
|
.fiches-list {
|
||||||
|
margin-top: 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.fiches-title {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--nav-text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.fiche-card {
|
||||||
|
display: block;
|
||||||
|
background: var(--nav-bg);
|
||||||
|
border: 1px solid var(--nav-bg-alt);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: border-color 0.15s, background 0.15s;
|
||||||
|
}
|
||||||
|
.fiche-card:hover {
|
||||||
|
border-color: var(--nav-primary);
|
||||||
|
background: var(--nav-surface);
|
||||||
|
}
|
||||||
|
.fiche-nom {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.825rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--nav-text);
|
||||||
|
}
|
||||||
|
.fiche-expl {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.775rem;
|
||||||
|
color: var(--nav-text-muted);
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Chargement */
|
||||||
|
.loading-bubble {
|
||||||
|
display: flex;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
}
|
||||||
|
.dot {
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
background: var(--nav-text-muted);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: blink 1.2s infinite ease-in-out;
|
||||||
|
}
|
||||||
|
.dot:nth-child(2) { animation-delay: 0.2s; }
|
||||||
|
.dot:nth-child(3) { animation-delay: 0.4s; }
|
||||||
|
@keyframes blink {
|
||||||
|
0%, 80%, 100% { opacity: 0.3; transform: scale(0.85); }
|
||||||
|
40% { opacity: 1; transform: scale(1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Erreur */
|
||||||
|
.error-bubble {
|
||||||
|
align-self: center;
|
||||||
|
background: rgba(220, 50, 50, 0.08);
|
||||||
|
border: 1px solid rgba(220, 50, 50, 0.2);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
font-size: 0.825rem;
|
||||||
|
color: #c0392b;
|
||||||
|
text-align: center;
|
||||||
|
max-width: 90%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.sheet-enter-active,
|
||||||
|
.sheet-leave-active { transition: none; }
|
||||||
|
.backdrop-enter-active,
|
||||||
|
.backdrop-leave-active { transition: none; }
|
||||||
|
.dot { animation: none; opacity: 0.5; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
147
components/CommentForm.vue
Normal file
147
components/CommentForm.vue
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
<template>
|
||||||
|
<section
|
||||||
|
class="rounded-2xl p-6"
|
||||||
|
style="background: var(--nav-bg-alt); border: 1px solid rgba(26,34,56,0.1);"
|
||||||
|
>
|
||||||
|
<h3 class="font-semibold mb-4" style="color: var(--nav-text);">Ajouter un commentaire</h3>
|
||||||
|
|
||||||
|
<!-- Succès -->
|
||||||
|
<div
|
||||||
|
v-if="success"
|
||||||
|
class="rounded-xl p-4 text-sm"
|
||||||
|
style="background: var(--nav-surface); color: var(--nav-text);"
|
||||||
|
>
|
||||||
|
<strong>Merci !</strong>
|
||||||
|
{{ successMessage }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Formulaire -->
|
||||||
|
<form v-else @submit.prevent="submit" class="space-y-4" novalidate>
|
||||||
|
|
||||||
|
<!-- Commentaire -->
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
for="comment-contenu"
|
||||||
|
class="block text-sm font-medium mb-1"
|
||||||
|
style="color: var(--nav-text);"
|
||||||
|
>
|
||||||
|
Commentaire <span aria-hidden="true">*</span>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="comment-contenu"
|
||||||
|
v-model="form.contenu"
|
||||||
|
required
|
||||||
|
rows="4"
|
||||||
|
minlength="10"
|
||||||
|
maxlength="500"
|
||||||
|
placeholder="Partage ton expérience avec cette organisation…"
|
||||||
|
class="w-full px-3 py-2 rounded-lg text-sm resize-none focus:outline-none focus:ring-2"
|
||||||
|
style="background: var(--nav-surface); color: var(--nav-text); border: 1px solid rgba(26,34,56,0.2); focus-ring-color: var(--nav-accent);"
|
||||||
|
:class="{ 'border-red-400': errors.contenu }"
|
||||||
|
/>
|
||||||
|
<div class="flex justify-between mt-1">
|
||||||
|
<span v-if="errors.contenu" class="text-xs text-red-500">{{ errors.contenu }}</span>
|
||||||
|
<span class="text-xs ml-auto" style="color: var(--nav-text-muted);">{{ form.contenu.length }}/500</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pseudo (optionnel) -->
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
for="comment-pseudo"
|
||||||
|
class="block text-sm font-medium mb-1"
|
||||||
|
style="color: var(--nav-text);"
|
||||||
|
>Pseudo <span class="font-normal" style="color: var(--nav-text-muted);">(optionnel)</span></label>
|
||||||
|
<input
|
||||||
|
id="comment-pseudo"
|
||||||
|
v-model="form.auteur_pseudo"
|
||||||
|
type="text"
|
||||||
|
maxlength="80"
|
||||||
|
placeholder="Marie A."
|
||||||
|
class="w-full px-3 py-2 rounded-lg text-sm focus:outline-none focus:ring-2"
|
||||||
|
style="background: var(--nav-surface); color: var(--nav-text); border: 1px solid rgba(26,34,56,0.2);"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Note modération -->
|
||||||
|
<p class="text-xs" style="color: var(--nav-text-muted);">
|
||||||
|
Vos commentaires sont filtrés par une IA avant publication.
|
||||||
|
Les critiques professionnelles factuelles sont les bienvenues.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Erreur serveur -->
|
||||||
|
<p v-if="serverError" class="text-xs text-red-500">{{ serverError }}</p>
|
||||||
|
|
||||||
|
<!-- Bouton -->
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
:disabled="submitting"
|
||||||
|
class="inline-flex items-center gap-2 px-5 py-2.5 rounded-lg text-sm font-medium transition-colors disabled:opacity-50"
|
||||||
|
style="background: var(--nav-primary); color: var(--nav-text-on-primary);"
|
||||||
|
@mouseenter="(e: MouseEvent) => { if (!submitting) (e.currentTarget as HTMLElement).style.background = 'rgba(26,34,56,0.75)' }"
|
||||||
|
@mouseleave="(e: MouseEvent) => { if (!submitting) (e.currentTarget as HTMLElement).style.background = 'var(--nav-primary)' }"
|
||||||
|
>
|
||||||
|
<svg v-if="submitting" class="animate-spin" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||||
|
<path d="M21 12a9 9 0 1 1-6.219-8.56"/>
|
||||||
|
</svg>
|
||||||
|
{{ submitting ? 'Envoi…' : 'Envoyer' }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const props = defineProps<{ orgId: number }>()
|
||||||
|
const emit = defineEmits<{ submitted: [] }>()
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
contenu: '',
|
||||||
|
auteur_pseudo: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const submitting = ref(false)
|
||||||
|
const success = ref(false)
|
||||||
|
const successMessage = ref('')
|
||||||
|
const serverError = ref('')
|
||||||
|
const errors = reactive({ contenu: '' })
|
||||||
|
|
||||||
|
function validate(): boolean {
|
||||||
|
errors.contenu = ''
|
||||||
|
const c = form.contenu.trim()
|
||||||
|
if (!c) { errors.contenu = 'Le commentaire est requis.'; return false }
|
||||||
|
if (c.length < 10) { errors.contenu = 'Minimum 10 caractères.'; return false }
|
||||||
|
if (c.length > 500) { errors.contenu = 'Maximum 500 caractères.'; return false }
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
serverError.value = ''
|
||||||
|
if (!validate()) return
|
||||||
|
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
|
const res = await $fetch<{ ok: boolean; status: string; message: string }>('/api/comment', {
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
orga_id: props.orgId,
|
||||||
|
contenu: form.contenu.trim(),
|
||||||
|
auteur_pseudo: form.auteur_pseudo.trim() || undefined,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
success.value = true
|
||||||
|
successMessage.value = res.message || 'Commentaire reçu.'
|
||||||
|
emit('submitted')
|
||||||
|
} catch (err: any) {
|
||||||
|
const status = err?.response?.status
|
||||||
|
if (status === 429) {
|
||||||
|
serverError.value = 'Trop de commentaires aujourd\'hui. Réessaie demain.'
|
||||||
|
} else {
|
||||||
|
serverError.value = 'Erreur lors de l\'envoi. Réessaie dans un moment.'
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
100
components/CommentSection.vue
Normal file
100
components/CommentSection.vue
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
<template>
|
||||||
|
<section class="mb-6">
|
||||||
|
<h2
|
||||||
|
class="text-lg font-semibold mb-4"
|
||||||
|
style="color: var(--nav-text);"
|
||||||
|
>
|
||||||
|
Commentaires
|
||||||
|
<span
|
||||||
|
v-if="comments.length"
|
||||||
|
class="ml-2 text-sm font-normal"
|
||||||
|
style="color: var(--nav-text-muted);"
|
||||||
|
>({{ comments.length }})</span>
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<!-- Texte d'intro -->
|
||||||
|
<p class="text-sm italic mb-4" style="color: var(--nav-text-muted);">
|
||||||
|
Les commentaires servent à confirmer ou attester de la fiabilité des services des organismes référencés.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Chargement -->
|
||||||
|
<div v-if="pending" class="text-sm py-4" style="color: var(--nav-text-muted);">
|
||||||
|
Chargement des commentaires…
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Liste -->
|
||||||
|
<div v-else-if="comments.length" class="space-y-3">
|
||||||
|
<article
|
||||||
|
v-for="comment in comments"
|
||||||
|
:key="comment.Id"
|
||||||
|
class="rounded-xl p-4"
|
||||||
|
style="background: var(--nav-surface); border: 1px solid var(--nav-bg-alt);"
|
||||||
|
>
|
||||||
|
<!-- Méta auteur + date -->
|
||||||
|
<div class="flex items-center gap-2 mb-2">
|
||||||
|
<!-- Avatar initiale -->
|
||||||
|
<div
|
||||||
|
class="w-7 h-7 rounded-full flex items-center justify-center text-xs font-semibold shrink-0"
|
||||||
|
style="background: var(--nav-primary); color: var(--nav-text-on-primary);"
|
||||||
|
>{{ initiale(comment.auteur_pseudo) }}</div>
|
||||||
|
|
||||||
|
<span class="text-sm font-medium" style="color: var(--nav-text);">
|
||||||
|
{{ comment.auteur_pseudo || 'Anonyme' }}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span v-if="comment.submitted_at" class="text-xs ml-auto" style="color: var(--nav-text-muted);">
|
||||||
|
{{ formatDate(comment.submitted_at) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Contenu -->
|
||||||
|
<p class="text-sm leading-relaxed" style="color: var(--nav-text);">{{ comment.contenu }}</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Vide -->
|
||||||
|
<p v-else class="text-sm italic py-2" style="color: var(--nav-text-muted);">
|
||||||
|
Aucun commentaire pour le moment. Partage ton expérience ci-dessous.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
interface Comment {
|
||||||
|
Id: number
|
||||||
|
auteur_pseudo?: string
|
||||||
|
contenu: string
|
||||||
|
orga_id: number
|
||||||
|
published?: boolean
|
||||||
|
submitted_at?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
orgId: number
|
||||||
|
refresh?: number // incrémenter pour forcer un rechargement
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { data, pending, refresh } = await useFetch<{ list: Comment[] }>(
|
||||||
|
`/api/comment/${props.orgId}`,
|
||||||
|
{ key: `comments-${props.orgId}` }
|
||||||
|
)
|
||||||
|
|
||||||
|
const comments = computed<Comment[]>(() => data.value?.list ?? [])
|
||||||
|
|
||||||
|
// Rechargement si le parent incrément refresh
|
||||||
|
watch(() => props.refresh, () => {
|
||||||
|
refresh()
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Helpers ─────────────────────────────────────────────────────────────
|
||||||
|
function initiale(pseudo?: string): string {
|
||||||
|
if (!pseudo) return '?'
|
||||||
|
return pseudo.trim().charAt(0).toUpperCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(iso?: string): string {
|
||||||
|
if (!iso) return ''
|
||||||
|
const d = new Date(iso)
|
||||||
|
return d.toLocaleDateString('fr-FR', { day: 'numeric', month: 'long', year: 'numeric' })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
63
components/EchelleFilter.vue
Normal file
63
components/EchelleFilter.vue
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
<template>
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<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 -->
|
||||||
|
<div class="flex flex-wrap gap-x-4 gap-y-1.5">
|
||||||
|
<label
|
||||||
|
v-for="option in ECHELLES"
|
||||||
|
:key="option"
|
||||||
|
class="flex items-center gap-1.5 cursor-pointer select-none transition-opacity"
|
||||||
|
>
|
||||||
|
<!-- Case carrée -->
|
||||||
|
<span
|
||||||
|
class="flex items-center justify-center shrink-0 transition-all"
|
||||||
|
style="width: 18px; height: 18px; border: 1.5px solid; border-radius: 3px;"
|
||||||
|
:style="isSelected(option)
|
||||||
|
? 'background: var(--nav-primary); border-color: var(--nav-primary); color: #ffffff;'
|
||||||
|
: 'background: var(--nav-bg-alt); border-color: rgba(26,34,56,0.25); color: transparent;'"
|
||||||
|
>
|
||||||
|
<svg v-if="isSelected(option)" width="11" height="11" viewBox="0 0 12 12" fill="none">
|
||||||
|
<polyline points="2,6 5,9 10,3" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<!-- Label -->
|
||||||
|
<span
|
||||||
|
class="text-sm leading-tight"
|
||||||
|
:style="isSelected(option) ? 'color: var(--nav-text); font-weight: 600;' : 'color: var(--nav-text);'"
|
||||||
|
>{{ option }}</span>
|
||||||
|
<!-- Input réel (masqué) -->
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="sr-only"
|
||||||
|
:checked="isSelected(option)"
|
||||||
|
@change="toggle(option)"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const ECHELLES = ['National', 'Régional', 'Local'] as const
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: string[]
|
||||||
|
counts: Record<string, number>
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: string[]]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
function isSelected(option: string): boolean {
|
||||||
|
return props.modelValue.includes(option)
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggle(option: string) {
|
||||||
|
if (isSelected(option)) {
|
||||||
|
emit('update:modelValue', props.modelValue.filter(v => v !== option))
|
||||||
|
} else {
|
||||||
|
emit('update:modelValue', [...props.modelValue, option])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
375
components/FicheDetail.vue
Normal file
375
components/FicheDetail.vue
Normal file
@@ -0,0 +1,375 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<!-- ─── En-tête ──────────────────────────────────────────────────── -->
|
||||||
|
<div
|
||||||
|
class="rounded-2xl overflow-hidden mb-6"
|
||||||
|
style="background: var(--nav-surface); border: 1px solid var(--nav-bg-alt);"
|
||||||
|
>
|
||||||
|
<!-- Bandeau titre -->
|
||||||
|
<div class="px-6 pt-6 pb-4" style="border-bottom: 1px solid var(--nav-bg-alt);">
|
||||||
|
<div class="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3 mb-3">
|
||||||
|
<h1
|
||||||
|
class="text-2xl font-bold leading-snug"
|
||||||
|
style="color: var(--nav-text);"
|
||||||
|
>{{ org.nom }}</h1>
|
||||||
|
|
||||||
|
<a
|
||||||
|
v-if="org.url"
|
||||||
|
:href="org.url"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="shrink-0 inline-flex items-center gap-1.5 text-sm font-medium px-3 py-1.5 rounded-lg transition-colors"
|
||||||
|
style="color: var(--nav-text); background: var(--nav-bg-alt);"
|
||||||
|
@mouseenter="(e: MouseEvent) => (e.target as HTMLElement).style.background = 'var(--nav-accent)'"
|
||||||
|
@mouseleave="(e: MouseEvent) => (e.target as HTMLElement).style.background = 'var(--nav-bg-alt)'"
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
|
||||||
|
<polyline points="15 3 21 3 21 9"/>
|
||||||
|
<line x1="10" y1="14" x2="21" y2="3"/>
|
||||||
|
</svg>
|
||||||
|
Visiter le site
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Méta : échelle + ville -->
|
||||||
|
<div class="flex flex-wrap items-center gap-2 text-sm mb-3" style="color: var(--nav-text-muted);">
|
||||||
|
<span
|
||||||
|
v-if="org.echelle"
|
||||||
|
class="px-2 py-0.5 rounded-full text-xs font-medium"
|
||||||
|
style="background: var(--nav-primary); color: var(--nav-text-on-primary);"
|
||||||
|
>{{ org.echelle }}</span>
|
||||||
|
<span v-if="org.territoire && org.territoire !== 'Métropole'" class="text-xs" style="color: var(--nav-text-muted);">{{ org.territoire }}</span>
|
||||||
|
<span v-if="org.localisation_ville" style="color: var(--nav-text-muted);">{{ org.localisation_ville }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tags fonction -->
|
||||||
|
<div v-if="fonctionTags.length" class="flex flex-wrap gap-1.5">
|
||||||
|
<span
|
||||||
|
v-for="tag in fonctionTags"
|
||||||
|
:key="tag"
|
||||||
|
class="px-2 py-0.5 rounded-full text-xs font-medium"
|
||||||
|
style="background: var(--nav-bg-alt); color: var(--nav-text);"
|
||||||
|
>{{ tag }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Corps : description + mini-carte -->
|
||||||
|
<div class="p-6 flex flex-col lg:flex-row gap-6">
|
||||||
|
|
||||||
|
<!-- Descriptions -->
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<!-- Description soumise par le contributeur -->
|
||||||
|
<div v-if="descriptionUser" class="mb-4">
|
||||||
|
<p class="text-sm leading-relaxed" style="color: var(--nav-text-muted);">Description communauté</p>
|
||||||
|
<p class="mt-1 leading-relaxed" style="color: var(--nav-text);">{{ descriptionUser }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Séparateur si les deux descriptions existent -->
|
||||||
|
<hr v-if="descriptionUser && descriptionEnrichie" style="border-color: var(--nav-bg-alt);" class="my-4" />
|
||||||
|
|
||||||
|
<!-- Description enrichie IA -->
|
||||||
|
<div v-if="descriptionEnrichie" class="mb-4">
|
||||||
|
<p class="text-sm leading-relaxed flex items-center gap-1.5" style="color: var(--nav-text-muted);">
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||||
|
<path d="M12 2L2 7l10 5 10-5-10-5z"/>
|
||||||
|
<path d="M2 17l10 5 10-5"/>
|
||||||
|
<path d="M2 12l10 5 10-5"/>
|
||||||
|
</svg>
|
||||||
|
Synthèse IA
|
||||||
|
</p>
|
||||||
|
<p class="mt-1 leading-relaxed" style="color: var(--nav-text);">{{ descriptionEnrichie }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Fallback description V1 -->
|
||||||
|
<div v-if="!descriptionUser && !descriptionEnrichie && org.description" class="mb-4">
|
||||||
|
<p class="leading-relaxed" style="color: var(--nav-text);">{{ org.description }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Points clés -->
|
||||||
|
<div v-if="pointsCles.length" class="mt-4">
|
||||||
|
<p class="text-sm font-medium mb-2" style="color: var(--nav-text-muted);">Points clés</p>
|
||||||
|
<ul class="space-y-1">
|
||||||
|
<li
|
||||||
|
v-for="(point, i) in pointsCles"
|
||||||
|
:key="i"
|
||||||
|
class="flex items-start gap-2 text-sm leading-relaxed"
|
||||||
|
style="color: var(--nav-text);"
|
||||||
|
>
|
||||||
|
<span class="mt-1 shrink-0 w-1.5 h-1.5 rounded-full" style="background: var(--nav-accent);"></span>
|
||||||
|
{{ point }}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mini-carte Leaflet -->
|
||||||
|
<div
|
||||||
|
v-if="hasCoords"
|
||||||
|
class="shrink-0 lg:w-56 xl:w-64 rounded-xl overflow-hidden"
|
||||||
|
style="height: 180px; border: 1px solid var(--nav-bg-alt);"
|
||||||
|
>
|
||||||
|
<ClientOnly>
|
||||||
|
<div ref="mapContainer" class="w-full h-full"></div>
|
||||||
|
<template #fallback>
|
||||||
|
<div
|
||||||
|
class="w-full h-full flex items-center justify-center text-xs"
|
||||||
|
style="background: var(--nav-bg-alt); color: var(--nav-text-muted);"
|
||||||
|
>Carte…</div>
|
||||||
|
</template>
|
||||||
|
</ClientOnly>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- ─── Signalement ────────────────────────────────────────────────── -->
|
||||||
|
<div class="mb-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="report-toggle"
|
||||||
|
@click="reportOpen = !reportOpen"
|
||||||
|
:aria-expanded="reportOpen"
|
||||||
|
>
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/>
|
||||||
|
<line x1="12" y1="9" x2="12" y2="13"/>
|
||||||
|
<line x1="12" y1="17" x2="12.01" y2="17"/>
|
||||||
|
</svg>
|
||||||
|
Signaler une erreur ou proposer une modification
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<Transition name="report-form">
|
||||||
|
<div v-if="reportOpen" class="report-panel">
|
||||||
|
<p class="text-xs mb-3" style="color: var(--nav-text-muted);">
|
||||||
|
Tes suggestions seront transmises à l'équipe AEP par email.
|
||||||
|
</p>
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
<textarea
|
||||||
|
v-model="reportMessage"
|
||||||
|
maxlength="500"
|
||||||
|
rows="3"
|
||||||
|
placeholder="Que proposes-tu de modifier ou signaler ? (max 500 caractères)"
|
||||||
|
class="report-input report-textarea"
|
||||||
|
:disabled="reportLoading"
|
||||||
|
/>
|
||||||
|
<div class="flex items-end gap-3">
|
||||||
|
<input
|
||||||
|
v-model="reportEmail"
|
||||||
|
type="email"
|
||||||
|
placeholder="Ton email (obligatoire)"
|
||||||
|
class="report-input flex-1"
|
||||||
|
:disabled="reportLoading"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="report-submit"
|
||||||
|
:disabled="reportLoading || !reportMessage.trim() || !reportEmail.trim()"
|
||||||
|
@click="submitReport"
|
||||||
|
>
|
||||||
|
{{ reportLoading ? 'Envoi…' : 'Envoyer' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p v-if="reportError" class="text-xs" style="color: #e53e3e;">{{ reportError }}</p>
|
||||||
|
<p v-if="reportSuccess" class="text-xs" style="color: #38a169;">{{ reportSuccess }}</p>
|
||||||
|
<p class="text-xs" style="color: var(--nav-text-muted); opacity: 0.6;">
|
||||||
|
{{ reportMessage.length }}/500
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { Org } from '~/types/org'
|
||||||
|
|
||||||
|
const props = defineProps<{ org: Org }>()
|
||||||
|
|
||||||
|
// ── Champs ──────────────────────────────────────────────────────────────
|
||||||
|
const descriptionUser = computed(() =>
|
||||||
|
props.org.description_user?.trim() || null
|
||||||
|
)
|
||||||
|
|
||||||
|
const descriptionEnrichie = computed(() =>
|
||||||
|
props.org.description_enrichie?.trim() || null
|
||||||
|
)
|
||||||
|
|
||||||
|
const fonctionTags = computed<string[]>(() => {
|
||||||
|
const raw = props.org.tags_fonction
|
||||||
|
if (!raw) return []
|
||||||
|
return raw.split(',').map((t) => t.trim()).filter(Boolean)
|
||||||
|
})
|
||||||
|
|
||||||
|
const pointsCles = computed<string[]>(() => {
|
||||||
|
const raw = props.org.points_cles
|
||||||
|
if (!raw) return []
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw)
|
||||||
|
if (Array.isArray(parsed)) return parsed.filter(Boolean)
|
||||||
|
} catch {
|
||||||
|
// Format texte brut — traiter chaque ligne
|
||||||
|
return raw.split('\n').map((l) => l.trim().replace(/^[-•*]\s*/, '')).filter(Boolean)
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
})
|
||||||
|
|
||||||
|
const hasCoords = computed(
|
||||||
|
() => !!props.org.latitude && !!props.org.longitude
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Signalement ────────────────────────────────────────────────────────
|
||||||
|
const reportOpen = ref(false)
|
||||||
|
const reportMessage = ref('')
|
||||||
|
const reportEmail = ref('')
|
||||||
|
const reportLoading = ref(false)
|
||||||
|
const reportError = ref('')
|
||||||
|
const reportSuccess = ref('')
|
||||||
|
|
||||||
|
async function submitReport() {
|
||||||
|
reportError.value = ''
|
||||||
|
reportSuccess.value = ''
|
||||||
|
|
||||||
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||||
|
if (!reportMessage.value.trim() || reportMessage.value.length < 5) {
|
||||||
|
reportError.value = 'Le message doit contenir au moins 5 caractères.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!emailRegex.test(reportEmail.value)) {
|
||||||
|
reportError.value = 'Adresse email invalide.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
reportLoading.value = true
|
||||||
|
try {
|
||||||
|
const res = await $fetch<{ ok: boolean; message: string }>('/api/report', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { fiche_id: props.org.Id, message: reportMessage.value, email: reportEmail.value },
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
reportSuccess.value = res.message
|
||||||
|
reportMessage.value = ''
|
||||||
|
reportEmail.value = ''
|
||||||
|
setTimeout(() => { reportOpen.value = false; reportSuccess.value = '' }, 3000)
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
reportError.value = e?.data?.statusMessage || 'Erreur lors de l\'envoi.'
|
||||||
|
} finally {
|
||||||
|
reportLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Mini-carte Leaflet ────────────────────────────────────────────────
|
||||||
|
const mapContainer = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
if (!hasCoords.value || !mapContainer.value) return
|
||||||
|
|
||||||
|
const L = (await import('leaflet')).default
|
||||||
|
|
||||||
|
const lat = props.org.latitude as number
|
||||||
|
const lng = props.org.longitude as number
|
||||||
|
|
||||||
|
const map = L.map(mapContainer.value, {
|
||||||
|
center: [lat, lng],
|
||||||
|
zoom: 10,
|
||||||
|
zoomControl: false,
|
||||||
|
dragging: false,
|
||||||
|
touchZoom: false,
|
||||||
|
doubleClickZoom: false,
|
||||||
|
scrollWheelZoom: false,
|
||||||
|
keyboard: false,
|
||||||
|
attributionControl: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||||
|
maxZoom: 18,
|
||||||
|
}).addTo(map)
|
||||||
|
|
||||||
|
// Pin personnalisé
|
||||||
|
const icon = L.divIcon({
|
||||||
|
className: '',
|
||||||
|
html: `<div style="
|
||||||
|
width:14px; height:14px; border-radius:50%;
|
||||||
|
background: rgba(26,34,56,0.6);
|
||||||
|
border: 2px solid white;
|
||||||
|
box-shadow: 0 1px 4px rgba(26,34,56,0.4);
|
||||||
|
"></div>`,
|
||||||
|
iconSize: [14, 14],
|
||||||
|
iconAnchor: [7, 7],
|
||||||
|
})
|
||||||
|
|
||||||
|
L.marker([lat, lng], { icon }).addTo(map)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* ── Signalement ─────────────────────────────────────────────────────── */
|
||||||
|
.report-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--nav-text-muted);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 6px 0;
|
||||||
|
opacity: 0.7;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-toggle:hover { opacity: 1; }
|
||||||
|
|
||||||
|
.report-panel {
|
||||||
|
margin-top: 10px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid var(--nav-bg-alt);
|
||||||
|
background: var(--nav-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--nav-bg-alt);
|
||||||
|
background: var(--nav-surface);
|
||||||
|
color: var(--nav-text);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-family: inherit;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-input:focus { border-color: var(--nav-primary-solid); }
|
||||||
|
.report-input:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||||
|
|
||||||
|
.report-textarea {
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 72px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-submit {
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: none;
|
||||||
|
background: var(--nav-primary-solid);
|
||||||
|
color: var(--nav-text-on-primary);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 600;
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
.report-submit:hover:not(:disabled) { opacity: 0.85; }
|
||||||
|
.report-submit:disabled { opacity: 0.45; cursor: not-allowed; }
|
||||||
|
|
||||||
|
/* Transition form */
|
||||||
|
.report-form-enter-active, .report-form-leave-active { transition: opacity 0.2s ease, max-height 0.2s ease; overflow: hidden; max-height: 300px; }
|
||||||
|
.report-form-enter-from, .report-form-leave-to { opacity: 0; max-height: 0; }
|
||||||
|
</style>
|
||||||
164
components/FicheModal.vue
Normal file
164
components/FicheModal.vue
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<!-- Backdrop -->
|
||||||
|
<Transition name="backdrop">
|
||||||
|
<div
|
||||||
|
v-if="modelValue && orgId != null"
|
||||||
|
class="fixed inset-0 z-[1500]"
|
||||||
|
style="background: rgba(26,34,56,0.55);"
|
||||||
|
@click="close"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</Transition>
|
||||||
|
|
||||||
|
<!-- Modal centré -->
|
||||||
|
<Transition name="modal">
|
||||||
|
<div
|
||||||
|
v-if="modelValue && orgId != null"
|
||||||
|
class="fixed z-[1501] top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 flex flex-col"
|
||||||
|
style="
|
||||||
|
width: min(768px, 92vw);
|
||||||
|
max-height: 90vh;
|
||||||
|
background: var(--nav-bg);
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow: 0 16px 64px rgba(26,34,56,0.28);
|
||||||
|
overflow: hidden;
|
||||||
|
"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
:aria-label="org?.nom ?? 'Fiche organisation'"
|
||||||
|
@keydown.esc="close"
|
||||||
|
>
|
||||||
|
<!-- Header modal -->
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-between px-5 py-3 shrink-0 border-b"
|
||||||
|
style="background: var(--nav-surface); border-color: var(--nav-bg-alt);"
|
||||||
|
>
|
||||||
|
<span class="text-sm font-semibold" style="color: var(--nav-text-muted);">Fiche détaillée</span>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<!-- Lien fiche complète -->
|
||||||
|
<a
|
||||||
|
v-if="orgId"
|
||||||
|
:href="`/fiche/${orgId}`"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-opacity hover:opacity-70"
|
||||||
|
style="background: var(--nav-bg-alt); color: var(--nav-text);"
|
||||||
|
>
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
|
||||||
|
<polyline points="15 3 21 3 21 9"/>
|
||||||
|
<line x1="10" y1="14" x2="21" y2="3"/>
|
||||||
|
</svg>
|
||||||
|
Ouvrir
|
||||||
|
</a>
|
||||||
|
<!-- Fermer -->
|
||||||
|
<button
|
||||||
|
@click="close"
|
||||||
|
class="w-8 h-8 rounded-lg flex items-center justify-center transition-colors"
|
||||||
|
style="background: var(--nav-bg-alt); color: var(--nav-text-muted);"
|
||||||
|
aria-label="Fermer"
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" aria-hidden="true">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Contenu scrollable -->
|
||||||
|
<div class="flex-1 overflow-y-auto px-5 py-5">
|
||||||
|
<!-- Chargement -->
|
||||||
|
<div v-if="pending" class="py-12 text-center text-sm" style="color: var(--nav-text-muted);">
|
||||||
|
Chargement de la fiche…
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Erreur -->
|
||||||
|
<div v-else-if="error" class="py-12 text-center">
|
||||||
|
<p class="text-base font-semibold mb-2" style="color: var(--nav-text);">Fiche introuvable</p>
|
||||||
|
<p class="text-sm" style="color: var(--nav-text-muted);">L'organisation demandée n'existe pas ou a été supprimée.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Contenu -->
|
||||||
|
<template v-else-if="org">
|
||||||
|
<FicheDetail :org="org" />
|
||||||
|
<div class="mb-5" style="height: 1px; background: var(--nav-bg-alt);"></div>
|
||||||
|
<CommentSection :org-id="org.Id" :refresh="commentRefreshTick" />
|
||||||
|
<CommentForm :org-id="org.Id" @submitted="onCommentSubmitted" />
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { Org } from '~/types/org'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: boolean
|
||||||
|
orgId: number | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: boolean]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
emit('update:modelValue', false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fermeture Esc globale
|
||||||
|
onMounted(() => {
|
||||||
|
const handler = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape' && props.modelValue) close()
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', handler)
|
||||||
|
onUnmounted(() => window.removeEventListener('keydown', handler))
|
||||||
|
})
|
||||||
|
|
||||||
|
// Fetch fiche quand orgId change
|
||||||
|
const org = ref<Org | null>(null)
|
||||||
|
const pending = ref(false)
|
||||||
|
const error = ref(false)
|
||||||
|
|
||||||
|
watch(() => props.orgId, async (id) => {
|
||||||
|
if (id == null) return
|
||||||
|
pending.value = true
|
||||||
|
error.value = false
|
||||||
|
org.value = null
|
||||||
|
try {
|
||||||
|
org.value = await $fetch<Org>(`/api/fiche/${id}`)
|
||||||
|
} catch {
|
||||||
|
error.value = true
|
||||||
|
} finally {
|
||||||
|
pending.value = false
|
||||||
|
}
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
|
const commentRefreshTick = ref(0)
|
||||||
|
|
||||||
|
function onCommentSubmitted() {
|
||||||
|
commentRefreshTick.value++
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* Backdrop */
|
||||||
|
.backdrop-enter-active, .backdrop-leave-active { transition: opacity 0.2s ease; }
|
||||||
|
.backdrop-enter-from, .backdrop-leave-to { opacity: 0; }
|
||||||
|
|
||||||
|
/* Modal */
|
||||||
|
.modal-enter-active, .modal-leave-active {
|
||||||
|
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||||
|
}
|
||||||
|
.modal-enter-from, .modal-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translate(-50%, -52%);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.backdrop-enter-active, .backdrop-leave-active { transition: none; }
|
||||||
|
.modal-enter-active, .modal-leave-active { transition: none; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
67
components/FonctionFilter.vue
Normal file
67
components/FonctionFilter.vue
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
<template>
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<p class="text-xs font-bold uppercase tracking-widest" style="color: var(--nav-text-muted);">Fonction</p>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<button
|
||||||
|
v-for="fn in FONCTIONS"
|
||||||
|
:key="fn"
|
||||||
|
@click="toggle(fn)"
|
||||||
|
:aria-pressed="modelValue.includes(fn)"
|
||||||
|
class="flex items-center gap-2.5 w-full rounded px-1 py-0.5 transition-all text-left hover:opacity-80"
|
||||||
|
:style="modelValue.includes(fn) ? 'background: rgba(26,34,56,0.06);' : ''"
|
||||||
|
>
|
||||||
|
<!-- Case : affiche le rang de priorité si actif, sinon le nombre d'orgs -->
|
||||||
|
<span
|
||||||
|
class="flex items-center justify-center shrink-0 text-xs font-bold transition-all"
|
||||||
|
style="width: 24px; height: 24px; border: 1.5px solid; border-radius: 4px;"
|
||||||
|
:style="modelValue.includes(fn)
|
||||||
|
? 'background: var(--nav-primary); border-color: var(--nav-primary); color: var(--nav-text-on-primary);'
|
||||||
|
: 'background: var(--nav-bg-alt); border-color: var(--nav-bg-alt); color: var(--nav-text-muted);'"
|
||||||
|
>
|
||||||
|
{{ modelValue.includes(fn) ? (modelValue.indexOf(fn) + 1) : (counts[fn] ?? 0) }}
|
||||||
|
</span>
|
||||||
|
<!-- Label -->
|
||||||
|
<span
|
||||||
|
class="text-sm leading-tight"
|
||||||
|
:style="modelValue.includes(fn) ? 'color: var(--nav-text); font-weight: 600;' : 'color: var(--nav-text);'"
|
||||||
|
>{{ fn }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p v-if="modelValue.length" class="text-xs pt-0.5" style="color: var(--nav-text-muted);">
|
||||||
|
{{ modelValue.length }} actif{{ modelValue.length > 1 ? 's' : '' }}
|
||||||
|
<button @click="emit('update:modelValue', [])" class="ml-2 underline hover:opacity-70">Effacer</button>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const FONCTIONS = [
|
||||||
|
'Juridique',
|
||||||
|
'Technique',
|
||||||
|
'Économique',
|
||||||
|
'Administratif',
|
||||||
|
'Chantier',
|
||||||
|
'Comptabilité',
|
||||||
|
'Développement',
|
||||||
|
'Formation',
|
||||||
|
"Gestion d'agence",
|
||||||
|
'Santé mentale',
|
||||||
|
] as const
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: string[]
|
||||||
|
counts: Record<string, number>
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: string[]]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
function toggle(fn: string) {
|
||||||
|
if (props.modelValue.includes(fn)) {
|
||||||
|
emit('update:modelValue', props.modelValue.filter(f => f !== fn))
|
||||||
|
} else {
|
||||||
|
emit('update:modelValue', [...props.modelValue, fn])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
218
components/MobileSheet.vue
Normal file
218
components/MobileSheet.vue
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
<!--
|
||||||
|
MobileSheet — Bottom sheet swipable 3 états (collapsed / half / full)
|
||||||
|
Pattern Google Maps mobile
|
||||||
|
|
||||||
|
États :
|
||||||
|
- collapsed : juste la poignée + compteur (~56px)
|
||||||
|
- half : ~50dvh (état initial)
|
||||||
|
- full : ~92dvh (plein écran)
|
||||||
|
|
||||||
|
Déclenché par touch/drag sur la poignée
|
||||||
|
-->
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="mobile-sheet"
|
||||||
|
:class="`mobile-sheet--${state}`"
|
||||||
|
:style="{ transform: `translateY(${dragOffset}px)` }"
|
||||||
|
>
|
||||||
|
<!-- Poignée drag -->
|
||||||
|
<div
|
||||||
|
class="sheet-handle-area"
|
||||||
|
@touchstart.passive="onTouchStart"
|
||||||
|
@touchmove.passive="onTouchMove"
|
||||||
|
@touchend="onTouchEnd"
|
||||||
|
>
|
||||||
|
<div class="sheet-handle-bar" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Compteur compact (toujours visible) -->
|
||||||
|
<div class="sheet-header" @click="onHeaderClick">
|
||||||
|
<span class="sheet-counter">
|
||||||
|
<span v-if="pending">Chargement…</span>
|
||||||
|
<span v-else>{{ resultCount }} fiche{{ resultCount > 1 ? 's' : '' }}</span>
|
||||||
|
</span>
|
||||||
|
<svg
|
||||||
|
width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||||
|
stroke-width="2.5" stroke-linecap="round" aria-hidden="true"
|
||||||
|
class="sheet-chevron"
|
||||||
|
:class="{ 'sheet-chevron--up': state !== 'collapsed' }"
|
||||||
|
style="color: var(--nav-text-muted); flex-shrink: 0;"
|
||||||
|
>
|
||||||
|
<polyline points="18 15 12 9 6 15"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Contenu scrollable (caché si collapsed) -->
|
||||||
|
<div
|
||||||
|
v-show="state !== 'collapsed'"
|
||||||
|
ref="contentEl"
|
||||||
|
class="sheet-content"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
type SheetState = 'collapsed' | 'half' | 'full'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
resultCount: number
|
||||||
|
pending?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'state-change': [state: SheetState]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const state = ref<SheetState>('half')
|
||||||
|
const dragOffset = ref(0)
|
||||||
|
const contentEl = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
|
let touchStartY = 0
|
||||||
|
let touchCurrentY = 0
|
||||||
|
let isDragging = false
|
||||||
|
|
||||||
|
// Cycle états au clic header
|
||||||
|
function onHeaderClick() {
|
||||||
|
if (state.value === 'collapsed') {
|
||||||
|
setSheetState('half')
|
||||||
|
} else if (state.value === 'half') {
|
||||||
|
setSheetState('full')
|
||||||
|
} else {
|
||||||
|
setSheetState('collapsed')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSheetState(next: SheetState) {
|
||||||
|
state.value = next
|
||||||
|
dragOffset.value = 0
|
||||||
|
emit('state-change', next)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Touch handlers
|
||||||
|
function onTouchStart(e: TouchEvent) {
|
||||||
|
touchStartY = e.touches[0].clientY
|
||||||
|
touchCurrentY = touchStartY
|
||||||
|
isDragging = true
|
||||||
|
dragOffset.value = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTouchMove(e: TouchEvent) {
|
||||||
|
if (!isDragging) return
|
||||||
|
touchCurrentY = e.touches[0].clientY
|
||||||
|
const delta = touchCurrentY - touchStartY
|
||||||
|
// Limiter le drag visuellement (résistance)
|
||||||
|
dragOffset.value = delta * 0.6
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTouchEnd() {
|
||||||
|
if (!isDragging) return
|
||||||
|
isDragging = false
|
||||||
|
|
||||||
|
const delta = touchCurrentY - touchStartY
|
||||||
|
const threshold = 60 // px minimum pour changer d'état
|
||||||
|
|
||||||
|
if (delta > threshold) {
|
||||||
|
// Swipe vers le bas → état inférieur
|
||||||
|
if (state.value === 'full') setSheetState('half')
|
||||||
|
else if (state.value === 'half') setSheetState('collapsed')
|
||||||
|
else setSheetState('collapsed')
|
||||||
|
} else if (delta < -threshold) {
|
||||||
|
// Swipe vers le haut → état supérieur
|
||||||
|
if (state.value === 'collapsed') setSheetState('half')
|
||||||
|
else if (state.value === 'half') setSheetState('full')
|
||||||
|
else setSheetState('full')
|
||||||
|
} else {
|
||||||
|
// Snap back
|
||||||
|
dragOffset.value = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transition smooth au relâchement
|
||||||
|
dragOffset.value = 0
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.mobile-sheet {
|
||||||
|
position: fixed;
|
||||||
|
inset-x: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 500;
|
||||||
|
background: var(--nav-surface);
|
||||||
|
border-radius: 16px 16px 0 0;
|
||||||
|
box-shadow: 0 -4px 24px rgba(26, 34, 56, 0.14);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
transition: height 0.3s cubic-bezier(0.32, 0.72, 0, 1), transform 0.05s linear;
|
||||||
|
will-change: height, transform;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── États hauteur ───────────────────────────────────────────────────── */
|
||||||
|
.mobile-sheet--collapsed {
|
||||||
|
height: 56px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-sheet--half {
|
||||||
|
height: 50dvh;
|
||||||
|
min-height: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-sheet--full {
|
||||||
|
height: 92dvh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Poignée ─────────────────────────────────────────────────────────── */
|
||||||
|
.sheet-handle-area {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 10px 0 4px;
|
||||||
|
cursor: grab;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet-handle-area:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet-handle-bar {
|
||||||
|
width: 36px;
|
||||||
|
height: 4px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: var(--nav-bg-alt);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Header compteur ─────────────────────────────────────────────────── */
|
||||||
|
.sheet-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0 16px 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet-counter {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--nav-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet-chevron {
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet-chevron--up {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Contenu scrollable ──────────────────────────────────────────────── */
|
||||||
|
.sheet-content {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
241
components/NavMap.vue
Normal file
241
components/NavMap.vue
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
<template>
|
||||||
|
<div class="relative w-full h-full">
|
||||||
|
<div ref="mapContainer" class="w-full h-full rounded-none" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { Map, Marker, DivIcon } from 'leaflet'
|
||||||
|
|
||||||
|
interface Org {
|
||||||
|
Id: number
|
||||||
|
nom: string
|
||||||
|
latitude?: number | null
|
||||||
|
longitude?: number | null
|
||||||
|
echelle?: string
|
||||||
|
tags_fonction?: string
|
||||||
|
territoire?: string
|
||||||
|
localisation_ville?: string
|
||||||
|
url?: string
|
||||||
|
prioritaire?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
orgs: Org[]
|
||||||
|
selectedId?: number | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'select-org': [id: number]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const mapContainer = ref<HTMLElement | null>(null)
|
||||||
|
let mapInstance: Map | null = null
|
||||||
|
let clusterGroup: any = null
|
||||||
|
const markers = new Map<number, Marker>()
|
||||||
|
let tileLayerInstance: any = null
|
||||||
|
|
||||||
|
// Créer une DivIcon pour les pins personnalisés
|
||||||
|
function createPinIcon(isPrioritaire: boolean, isSelected = false): DivIcon {
|
||||||
|
const L = (window as any).L
|
||||||
|
const bg = isPrioritaire ? '#f5b342' : 'rgba(26, 34, 56, 0.6)'
|
||||||
|
const border = isPrioritaire ? '#1a2238' : '#ffffff'
|
||||||
|
const size = isSelected ? 18 : 14
|
||||||
|
const shadow = isSelected ? '0 0 0 4px rgba(245,179,66,0.5)' : 'none'
|
||||||
|
|
||||||
|
return L.divIcon({
|
||||||
|
className: '',
|
||||||
|
html: `<div style="
|
||||||
|
width: ${size}px;
|
||||||
|
height: ${size}px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: ${bg};
|
||||||
|
border: 2px solid ${border};
|
||||||
|
box-shadow: ${shadow};
|
||||||
|
transition: all 0.2s;
|
||||||
|
"></div>`,
|
||||||
|
iconSize: [size, size],
|
||||||
|
iconAnchor: [size / 2, size / 2],
|
||||||
|
popupAnchor: [0, -(size / 2 + 4)],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initMap() {
|
||||||
|
if (!mapContainer.value) return
|
||||||
|
|
||||||
|
const Lmod = await import('leaflet')
|
||||||
|
const L: any = (Lmod as any).default || Lmod
|
||||||
|
await import('leaflet/dist/leaflet.css')
|
||||||
|
// @ts-ignore
|
||||||
|
await import('leaflet.markercluster/dist/MarkerCluster.css')
|
||||||
|
// @ts-ignore
|
||||||
|
await import('leaflet.markercluster/dist/MarkerCluster.Default.css')
|
||||||
|
|
||||||
|
// Installer L globalement AVANT le plugin (markercluster lit window.L au load)
|
||||||
|
;(window as any).L = L
|
||||||
|
// @ts-ignore — étend L.MarkerClusterGroup en side effect
|
||||||
|
await import('leaflet.markercluster')
|
||||||
|
const MarkerClusterGroup = L.MarkerClusterGroup
|
||||||
|
|
||||||
|
mapInstance = L.map(mapContainer.value, {
|
||||||
|
center: [46.6, 2.3],
|
||||||
|
zoom: 6,
|
||||||
|
zoomControl: true,
|
||||||
|
attributionControl: true,
|
||||||
|
maxBounds: [[41.0, -5.5], [51.5, 10.0]],
|
||||||
|
maxBoundsViscosity: 1.0,
|
||||||
|
minZoom: 5,
|
||||||
|
maxZoom: 18,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Fond de carte CartoDB Positron (light ou dark selon theme)
|
||||||
|
const isDark = typeof document !== 'undefined' && document.documentElement.classList.contains('dark')
|
||||||
|
const tileUrl = isDark
|
||||||
|
? 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'
|
||||||
|
: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png'
|
||||||
|
|
||||||
|
tileLayerInstance = L.tileLayer(tileUrl, {
|
||||||
|
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors © <a href="https://carto.com/attributions">CARTO</a>',
|
||||||
|
maxZoom: 19,
|
||||||
|
})
|
||||||
|
tileLayerInstance.addTo(mapInstance!)
|
||||||
|
|
||||||
|
// Cluster dès 15+ pins
|
||||||
|
clusterGroup = new MarkerClusterGroup({
|
||||||
|
disableClusteringAtZoom: 14,
|
||||||
|
maxClusterRadius: 50,
|
||||||
|
showCoverageOnHover: false,
|
||||||
|
iconCreateFunction: (cluster: any) => {
|
||||||
|
const count = cluster.getChildCount()
|
||||||
|
return L.divIcon({
|
||||||
|
html: `<div style="
|
||||||
|
width: 36px; height: 36px; border-radius: 50%;
|
||||||
|
background: var(--nav-primary);
|
||||||
|
color: var(--nav-text-on-primary);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
font-weight: 700; font-size: 13px;
|
||||||
|
border: 2px solid white;
|
||||||
|
font-family: var(--nav-font);
|
||||||
|
">${count}</div>`,
|
||||||
|
className: '',
|
||||||
|
iconSize: [36, 36],
|
||||||
|
iconAnchor: [18, 18],
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
mapInstance.addLayer(clusterGroup)
|
||||||
|
updateMarkers(L)
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateMarkers(L?: any) {
|
||||||
|
if (!mapInstance || !clusterGroup) return
|
||||||
|
const leaflet = L || (window as any).L
|
||||||
|
if (!leaflet) return
|
||||||
|
|
||||||
|
// Clear existing
|
||||||
|
clusterGroup.clearLayers()
|
||||||
|
markers.clear()
|
||||||
|
|
||||||
|
const orgsWithCoords = props.orgs.filter(
|
||||||
|
(o) => o.latitude != null && o.longitude != null
|
||||||
|
)
|
||||||
|
|
||||||
|
orgsWithCoords.forEach((org) => {
|
||||||
|
const isSelected = org.Id === props.selectedId
|
||||||
|
const icon = createPinIcon(!!org.prioritaire, isSelected)
|
||||||
|
|
||||||
|
const marker = leaflet.marker([org.latitude!, org.longitude!], { icon })
|
||||||
|
|
||||||
|
const fonctions = org.tags_fonction
|
||||||
|
? org.tags_fonction.split(',').map((f: string) => f.trim()).filter(Boolean).slice(0, 2).join(', ')
|
||||||
|
: ''
|
||||||
|
|
||||||
|
marker.bindPopup(`
|
||||||
|
<div style="font-family: var(--nav-font); min-width: 180px; padding: 4px 0;">
|
||||||
|
<div style="font-weight: 700; color: var(--nav-text); margin-bottom: 4px;">${org.nom}</div>
|
||||||
|
${org.echelle ? `<div style="font-size: 11px; color: var(--nav-text-muted);">${org.echelle}${org.localisation_ville ? ' · ' + org.localisation_ville : ''}</div>` : ''}
|
||||||
|
${fonctions ? `<div style="font-size: 11px; color: var(--nav-text-muted); margin-top: 2px;">${fonctions}</div>` : ''}
|
||||||
|
<a href="/fiche/${org.Id}" style="
|
||||||
|
display: inline-block; margin-top: 8px; font-size: 12px;
|
||||||
|
color: var(--nav-primary-solid); text-decoration: underline;
|
||||||
|
">Voir la fiche →</a>
|
||||||
|
</div>
|
||||||
|
`, { maxWidth: 240 })
|
||||||
|
|
||||||
|
marker.on('click', () => emit('select-org', org.Id))
|
||||||
|
|
||||||
|
markers.set(org.Id, marker)
|
||||||
|
clusterGroup.addLayer(marker)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Réagir aux changements de filtres (liste d'orgs)
|
||||||
|
watch(
|
||||||
|
() => props.orgs,
|
||||||
|
() => updateMarkers(),
|
||||||
|
{ deep: false }
|
||||||
|
)
|
||||||
|
|
||||||
|
// Réagir à la sélection
|
||||||
|
watch(
|
||||||
|
() => props.selectedId,
|
||||||
|
(newId, oldId) => {
|
||||||
|
if (!mapInstance) return
|
||||||
|
const leaflet = (window as any).L
|
||||||
|
if (!leaflet) return
|
||||||
|
|
||||||
|
// Remettre l'ancien marker à la normale
|
||||||
|
if (oldId != null) {
|
||||||
|
const oldMarker = markers.get(oldId)
|
||||||
|
const oldOrg = props.orgs.find(o => o.Id === oldId)
|
||||||
|
if (oldMarker && oldOrg) {
|
||||||
|
oldMarker.setIcon(createPinIcon(!!oldOrg.prioritaire, false))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Mettre en avant le nouveau marker
|
||||||
|
if (newId != null) {
|
||||||
|
const newMarker = markers.get(newId)
|
||||||
|
const newOrg = props.orgs.find(o => o.Id === newId)
|
||||||
|
if (newMarker && newOrg) {
|
||||||
|
newMarker.setIcon(createPinIcon(!!newOrg.prioritaire, true))
|
||||||
|
// Centrer si visible
|
||||||
|
const latLng = newMarker.getLatLng()
|
||||||
|
mapInstance.panTo(latLng, { animate: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Watcher dark mode — switch tuile CartoDB light_all ↔ dark_all
|
||||||
|
function updateTileTheme(dark: boolean) {
|
||||||
|
if (!mapInstance || !tileLayerInstance) return
|
||||||
|
const L = (window as any).L
|
||||||
|
if (!L) return
|
||||||
|
const url = dark
|
||||||
|
? 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'
|
||||||
|
: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png'
|
||||||
|
tileLayerInstance.setUrl(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
let themeObserver: MutationObserver | null = null
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
initMap()
|
||||||
|
|
||||||
|
// Observer les changements de classe dark sur <html>
|
||||||
|
themeObserver = new MutationObserver(() => {
|
||||||
|
const dark = document.documentElement.classList.contains('dark')
|
||||||
|
updateTileTheme(dark)
|
||||||
|
})
|
||||||
|
themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] })
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
themeObserver?.disconnect()
|
||||||
|
if (mapInstance) {
|
||||||
|
mapInstance.remove()
|
||||||
|
mapInstance = null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
257
components/NavSidebar.vue
Normal file
257
components/NavSidebar.vue
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
<template>
|
||||||
|
<aside
|
||||||
|
class="flex flex-col h-full overflow-hidden"
|
||||||
|
style="background: var(--nav-surface); border-right: 1px solid var(--nav-bg-alt);"
|
||||||
|
>
|
||||||
|
|
||||||
|
<!-- ═══════════════════════════════════ BARRE DE RECHERCHE (tout en haut) -->
|
||||||
|
<div
|
||||||
|
class="shrink-0 px-4 pt-4 pb-3 border-b"
|
||||||
|
style="border-color: var(--nav-bg-alt);"
|
||||||
|
>
|
||||||
|
<label class="sidebar-search-label" aria-label="Rechercher une organisation">
|
||||||
|
<svg
|
||||||
|
width="15" height="15" viewBox="0 0 24 24"
|
||||||
|
fill="none" stroke="currentColor" stroke-width="2"
|
||||||
|
stroke-linecap="round" stroke-linejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
class="sidebar-search-icon"
|
||||||
|
>
|
||||||
|
<circle cx="11" cy="11" r="8"/>
|
||||||
|
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
|
||||||
|
</svg>
|
||||||
|
<input
|
||||||
|
ref="searchInputEl"
|
||||||
|
:value="search"
|
||||||
|
type="search"
|
||||||
|
placeholder="Rechercher une organisation…"
|
||||||
|
class="sidebar-search-input"
|
||||||
|
autocomplete="off"
|
||||||
|
@input="emit('update:search', ($event.target as HTMLInputElement).value)"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
v-if="search"
|
||||||
|
type="button"
|
||||||
|
class="sidebar-search-clear"
|
||||||
|
aria-label="Effacer la recherche"
|
||||||
|
@click.stop="emit('update:search', '')"
|
||||||
|
>
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18"/>
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ═══════════════════════════════════ FILTRES (haut, compact) -->
|
||||||
|
<div
|
||||||
|
class="shrink-0 px-4 pt-3 pb-3 space-y-4 border-b"
|
||||||
|
style="border-color: var(--nav-bg-alt);"
|
||||||
|
>
|
||||||
|
<!-- Échelle (checkbox compactes) -->
|
||||||
|
<EchelleFilter
|
||||||
|
:modelValue="echelle"
|
||||||
|
:counts="echelleCount"
|
||||||
|
@update:modelValue="emit('update:echelle', $event)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Fonctions (checkbox compactes) -->
|
||||||
|
<FonctionFilter
|
||||||
|
:modelValue="fonctions"
|
||||||
|
:counts="fonctionCount"
|
||||||
|
@update:modelValue="emit('update:fonctions', $event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ═══════════════════════════════════ LISTE FICHES (milieu, scrollable) -->
|
||||||
|
<div class="flex-1 flex flex-col min-h-0">
|
||||||
|
<!-- Header liste -->
|
||||||
|
<div
|
||||||
|
class="shrink-0 flex items-center justify-between px-4 py-2 border-b"
|
||||||
|
style="border-color: var(--nav-bg-alt);"
|
||||||
|
>
|
||||||
|
<span class="text-xs font-bold uppercase tracking-widest" style="color: var(--nav-text-muted);">
|
||||||
|
{{ resultCount }} résultat{{ resultCount > 1 ? 's' : '' }}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
v-if="hasActiveFilters"
|
||||||
|
@click="emit('reset-filters')"
|
||||||
|
class="text-xs underline hover:opacity-70"
|
||||||
|
style="color: var(--nav-text-muted);"
|
||||||
|
>
|
||||||
|
Effacer les filtres
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Liste scrollable -->
|
||||||
|
<div class="flex-1 overflow-y-auto px-3 py-2 space-y-1.5">
|
||||||
|
<div
|
||||||
|
v-if="pending"
|
||||||
|
class="flex items-center justify-center py-8"
|
||||||
|
style="color: var(--nav-text-muted);"
|
||||||
|
>
|
||||||
|
Chargement…
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="orgs.length === 0" class="text-center py-8">
|
||||||
|
<p class="text-xs" style="color: var(--nav-text-muted);">Aucun résultat</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Card fiche compacte -->
|
||||||
|
<div
|
||||||
|
v-for="org in orgs"
|
||||||
|
:key="org.Id"
|
||||||
|
class="rounded-lg px-3 py-2 cursor-pointer transition-all"
|
||||||
|
:style="selectedId === org.Id
|
||||||
|
? 'background: var(--nav-bg-alt); border-left: 3px solid var(--nav-accent); padding-left: 9px;'
|
||||||
|
: 'background: var(--nav-bg); border-left: 3px solid transparent; padding-left: 9px;'"
|
||||||
|
@click="emit('select-org', org.Id)"
|
||||||
|
@mouseenter="emit('hover-org', org.Id)"
|
||||||
|
@mouseleave="emit('hover-org', null)"
|
||||||
|
>
|
||||||
|
<div class="flex items-start justify-between gap-1.5">
|
||||||
|
<span
|
||||||
|
class="font-semibold text-sm leading-snug"
|
||||||
|
style="color: var(--nav-text);"
|
||||||
|
>{{ org.nom }}</span>
|
||||||
|
<span
|
||||||
|
v-if="org.echelle"
|
||||||
|
class="shrink-0 px-1.5 py-0.5 rounded-full text-xs"
|
||||||
|
style="background: var(--nav-bg-alt); color: var(--nav-text-muted); margin-top: 1px;"
|
||||||
|
>{{ org.echelle }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="orgFonctions(org).length" class="mt-1 flex flex-wrap gap-1">
|
||||||
|
<span
|
||||||
|
v-for="fn in orgFonctions(org)"
|
||||||
|
:key="fn"
|
||||||
|
class="px-1.5 py-0.5 rounded text-xs"
|
||||||
|
style="background: var(--nav-bg-alt); color: var(--nav-text-muted);"
|
||||||
|
>{{ fn }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="org.localisation_ville" class="mt-0.5 text-xs" style="color: var(--nav-text-muted);">
|
||||||
|
{{ org.localisation_ville }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
</aside>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
interface Org {
|
||||||
|
Id: number
|
||||||
|
nom: string
|
||||||
|
echelle?: string
|
||||||
|
tags_fonction?: string
|
||||||
|
territoire?: string
|
||||||
|
localisation_ville?: string
|
||||||
|
latitude?: number | null
|
||||||
|
longitude?: number | null
|
||||||
|
prioritaire?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
search: string
|
||||||
|
modeValue: string // 'metropole' | 'outremer'
|
||||||
|
echelle: string[]
|
||||||
|
fonctions: string[]
|
||||||
|
territoire: string | null
|
||||||
|
echelleCount: Record<string, number>
|
||||||
|
fonctionCount: Record<string, number>
|
||||||
|
territoireCount: Record<string, number>
|
||||||
|
resultCount: number
|
||||||
|
orgs: Org[] // fiches filtrées à afficher dans la liste
|
||||||
|
selectedId: number | null
|
||||||
|
hasActiveFilters: boolean
|
||||||
|
pending?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:search': [value: string]
|
||||||
|
'update:mode': [value: string]
|
||||||
|
'update:echelle': [value: string[]]
|
||||||
|
'update:fonctions': [value: string[]]
|
||||||
|
'update:territoire': [value: string | null]
|
||||||
|
'select-org': [id: number]
|
||||||
|
'hover-org': [id: number | null]
|
||||||
|
'reset-filters': []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const searchInputEl = ref<HTMLInputElement | null>(null)
|
||||||
|
|
||||||
|
function orgFonctions(org: Org): string[] {
|
||||||
|
return (org.tags_fonction ?? '').split(',').map(f => f.trim()).filter(Boolean).slice(0, 2)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.sidebar-search-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
border: 1.5px solid var(--nav-bg-alt);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--nav-bg);
|
||||||
|
padding: 7px 10px;
|
||||||
|
cursor: text;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-search-label:focus-within {
|
||||||
|
border-color: var(--nav-primary);
|
||||||
|
background: var(--nav-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-search-icon {
|
||||||
|
color: var(--nav-text-muted);
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-search-label:focus-within .sidebar-search-icon {
|
||||||
|
color: var(--nav-primary-solid);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-search-input {
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--nav-text);
|
||||||
|
font-size: 13px;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
font-family: var(--nav-font);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-search-input::placeholder {
|
||||||
|
color: var(--nav-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-search-input::-webkit-search-cancel-button {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-search-clear {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--nav-text-muted);
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 2px;
|
||||||
|
border-radius: 50%;
|
||||||
|
transition: color 0.15s, background 0.15s;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-search-clear:hover {
|
||||||
|
color: var(--nav-text);
|
||||||
|
background: var(--nav-bg-alt);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
45
components/OrgCard.vue
Normal file
45
components/OrgCard.vue
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
<template>
|
||||||
|
<NuxtLink
|
||||||
|
:to="`/fiche/${org.Id}`"
|
||||||
|
class="block bg-white rounded-xl shadow-sm border border-warm-200 hover:shadow-md hover:border-sage-300 transition-all duration-200 p-5"
|
||||||
|
>
|
||||||
|
<div class="flex items-start justify-between gap-3 mb-2">
|
||||||
|
<h2 class="font-semibold text-gray-900 text-base leading-snug">{{ org.nom }}</h2>
|
||||||
|
<TypeBadge v-if="org.type_org" :type="org.type_org" class="shrink-0 mt-0.5" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-gray-600 text-sm leading-relaxed mb-3 line-clamp-2">
|
||||||
|
{{ org.description }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div v-if="tags.length" class="flex flex-wrap gap-1.5">
|
||||||
|
<TagBadge
|
||||||
|
v-for="tag in tags"
|
||||||
|
:key="tag"
|
||||||
|
:tag="tag"
|
||||||
|
@click="$emit('filter-tag', tag)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</NuxtLink>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const props = defineProps<{
|
||||||
|
org: {
|
||||||
|
Id: number
|
||||||
|
nom: string
|
||||||
|
type_org?: string
|
||||||
|
description?: string
|
||||||
|
tags?: string
|
||||||
|
lien?: string
|
||||||
|
}
|
||||||
|
}>()
|
||||||
|
|
||||||
|
defineEmits<{ 'filter-tag': [tag: string] }>()
|
||||||
|
|
||||||
|
const tags = computed(() =>
|
||||||
|
props.org.tags
|
||||||
|
? props.org.tags.split(',').map((t) => t.trim()).filter(Boolean)
|
||||||
|
: []
|
||||||
|
)
|
||||||
|
</script>
|
||||||
279
components/OutremerMap.vue
Normal file
279
components/OutremerMap.vue
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
<template>
|
||||||
|
<div class="outremer-accordion">
|
||||||
|
<div
|
||||||
|
v-for="dom in DOM_TOM"
|
||||||
|
:key="dom.name"
|
||||||
|
class="outremer-item"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="outremer-header"
|
||||||
|
@click="toggle(dom.name)"
|
||||||
|
:aria-expanded="openDom === dom.name"
|
||||||
|
>
|
||||||
|
<span class="outremer-title">{{ dom.name }}</span>
|
||||||
|
<span class="outremer-meta">
|
||||||
|
<span class="outremer-count-badge" :style="orgCounts[dom.name] === 0 ? 'opacity:0.4' : ''">
|
||||||
|
{{ orgCounts[dom.name] ?? 0 }} fiche{{ (orgCounts[dom.name] ?? 0) > 1 ? 's' : '' }}
|
||||||
|
</span>
|
||||||
|
<svg
|
||||||
|
width="14" height="14" viewBox="0 0 24 24" fill="none"
|
||||||
|
stroke="currentColor" stroke-width="2.5" stroke-linecap="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
class="outremer-chevron"
|
||||||
|
:class="{ 'outremer-chevron--open': openDom === dom.name }"
|
||||||
|
>
|
||||||
|
<polyline points="6 9 12 15 18 9"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-show="openDom === dom.name"
|
||||||
|
class="outremer-map-container"
|
||||||
|
>
|
||||||
|
<div :ref="el => { if (el) mapRefs[dom.name] = el as HTMLElement }" class="outremer-map" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { Map as LeafletMap, TileLayer } from 'leaflet'
|
||||||
|
|
||||||
|
interface Org {
|
||||||
|
Id: number
|
||||||
|
nom: string
|
||||||
|
latitude?: number | null
|
||||||
|
longitude?: number | null
|
||||||
|
territoire?: string
|
||||||
|
echelle?: string
|
||||||
|
tags_fonction?: string
|
||||||
|
localisation_ville?: string
|
||||||
|
prioritaire?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const DOM_TOM = [
|
||||||
|
{ name: 'Guadeloupe', center: [16.25, -61.58] as [number, number], zoom: 9 },
|
||||||
|
{ name: 'Martinique', center: [14.65, -61.02] as [number, number], zoom: 9 },
|
||||||
|
{ name: 'Guyane', center: [4.0, -53.0] as [number, number], zoom: 6 },
|
||||||
|
{ name: 'La Réunion', center: [-21.11, 55.53] as [number, number], zoom: 9 },
|
||||||
|
{ name: 'Mayotte', center: [-12.83, 45.16] as [number, number], zoom: 10 },
|
||||||
|
]
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
orgs: Org[]
|
||||||
|
selectedId?: number | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'select-org': [id: number]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const mapRefs: Record<string, HTMLElement> = {}
|
||||||
|
const mapInstances: Record<string, LeafletMap> = {}
|
||||||
|
const tileLayers: Record<string, TileLayer> = {}
|
||||||
|
|
||||||
|
const openDom = ref<string | null>(null)
|
||||||
|
|
||||||
|
const orgCounts = computed<Record<string, number>>(() => {
|
||||||
|
const counts: Record<string, number> = {}
|
||||||
|
DOM_TOM.forEach(d => { counts[d.name] = 0 })
|
||||||
|
props.orgs.forEach(o => {
|
||||||
|
if (o.territoire && counts[o.territoire] !== undefined) {
|
||||||
|
counts[o.territoire]++
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return counts
|
||||||
|
})
|
||||||
|
|
||||||
|
function toggle(name: string) {
|
||||||
|
openDom.value = openDom.value === name ? null : name
|
||||||
|
nextTick(() => {
|
||||||
|
if (openDom.value === name && !mapInstances[name]) {
|
||||||
|
initSingleMap(name)
|
||||||
|
} else if (openDom.value === name) {
|
||||||
|
mapInstances[name]?.invalidateSize()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPinIcon(L: any, isPrioritaire: boolean, isSelected = false) {
|
||||||
|
const bg = isPrioritaire ? '#f5b342' : 'rgba(26, 34, 56, 0.6)'
|
||||||
|
const border = isPrioritaire ? '#1a2238' : '#ffffff'
|
||||||
|
const size = isSelected ? 16 : 12
|
||||||
|
return L.divIcon({
|
||||||
|
className: '',
|
||||||
|
html: `<div style="width:${size}px;height:${size}px;border-radius:50%;background:${bg};border:2px solid ${border};"></div>`,
|
||||||
|
iconSize: [size, size],
|
||||||
|
iconAnchor: [size / 2, size / 2],
|
||||||
|
popupAnchor: [0, -(size / 2 + 4)],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTileUrl(dark: boolean) {
|
||||||
|
return dark
|
||||||
|
? 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'
|
||||||
|
: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initSingleMap(domName: string) {
|
||||||
|
const dom = DOM_TOM.find(d => d.name === domName)
|
||||||
|
if (!dom) return
|
||||||
|
const Lmod = await import('leaflet')
|
||||||
|
const L: any = (Lmod as any).default || Lmod
|
||||||
|
await import('leaflet/dist/leaflet.css')
|
||||||
|
const isDark = typeof document !== 'undefined' && document.documentElement.classList.contains('dark')
|
||||||
|
const el = mapRefs[domName]
|
||||||
|
if (!el) return
|
||||||
|
const map = L.map(el, {
|
||||||
|
center: dom.center, zoom: dom.zoom,
|
||||||
|
zoomControl: false, attributionControl: false,
|
||||||
|
dragging: true, scrollWheelZoom: true, doubleClickZoom: true,
|
||||||
|
touchZoom: true, keyboard: false,
|
||||||
|
})
|
||||||
|
const tileLayer = L.tileLayer(getTileUrl(isDark), {
|
||||||
|
attribution: '© OpenStreetMap contributors © CARTO', maxZoom: 19,
|
||||||
|
})
|
||||||
|
tileLayer.addTo(map)
|
||||||
|
tileLayers[domName] = tileLayer as unknown as TileLayer
|
||||||
|
mapInstances[domName] = map as unknown as LeafletMap
|
||||||
|
renderPins(L, domName)
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateTheme(dark: boolean) {
|
||||||
|
const url = getTileUrl(dark)
|
||||||
|
Object.values(tileLayers).forEach(tl => {
|
||||||
|
(tl as any).setUrl(url)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPins(L: any, domName: string) {
|
||||||
|
const map = mapInstances[domName] as any
|
||||||
|
if (!map) return
|
||||||
|
|
||||||
|
if (map._navMarkers) {
|
||||||
|
map._navMarkers.forEach((m: any) => m.remove())
|
||||||
|
}
|
||||||
|
map._navMarkers = []
|
||||||
|
|
||||||
|
const domOrgs = props.orgs.filter(o => o.territoire === domName && o.latitude != null && o.longitude != null)
|
||||||
|
domOrgs.forEach(org => {
|
||||||
|
const icon = createPinIcon(L, !!org.prioritaire, org.Id === props.selectedId)
|
||||||
|
const marker = L.marker([org.latitude!, org.longitude!], { icon })
|
||||||
|
|
||||||
|
const fonctions = org.tags_fonction
|
||||||
|
? org.tags_fonction.split(',').map((f: string) => f.trim()).filter(Boolean).slice(0, 2).join(', ')
|
||||||
|
: ''
|
||||||
|
|
||||||
|
marker.bindPopup(`
|
||||||
|
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;min-width:160px;padding:4px 0;">
|
||||||
|
<div style="font-weight:700;color:#1a2238;margin-bottom:2px;">${org.nom}</div>
|
||||||
|
${org.echelle ? `<div style="font-size:11px;color:rgba(26,34,56,0.55);">${org.echelle}${org.localisation_ville ? ' · ' + org.localisation_ville : ''}</div>` : ''}
|
||||||
|
${fonctions ? `<div style="font-size:11px;color:rgba(26,34,56,0.55);margin-top:2px;">${fonctions}</div>` : ''}
|
||||||
|
<a href="/fiche/${org.Id}" style="display:inline-block;margin-top:8px;font-size:12px;color:#1a2238;text-decoration:underline;">Voir la fiche →</a>
|
||||||
|
</div>
|
||||||
|
`, { maxWidth: 200 })
|
||||||
|
|
||||||
|
marker.on('click', () => emit('select-org', org.Id))
|
||||||
|
marker.addTo(map)
|
||||||
|
map._navMarkers.push(marker)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => props.orgs, () => {
|
||||||
|
DOM_TOM.forEach(dom => {
|
||||||
|
if (mapInstances[dom.name]) {
|
||||||
|
import('leaflet').then(L => renderPins(L, dom.name))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, { deep: false })
|
||||||
|
|
||||||
|
watch(() => props.selectedId, () => {
|
||||||
|
DOM_TOM.forEach(dom => {
|
||||||
|
if (mapInstances[dom.name]) {
|
||||||
|
import('leaflet').then(L => renderPins(L, dom.name))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
let themeObserver: MutationObserver | null = null
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
themeObserver = new MutationObserver(() => {
|
||||||
|
const dark = document.documentElement.classList.contains('dark')
|
||||||
|
updateTheme(dark)
|
||||||
|
})
|
||||||
|
themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] })
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
themeObserver?.disconnect()
|
||||||
|
Object.values(mapInstances).forEach(m => (m as any).remove())
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.outremer-accordion {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.outremer-item {
|
||||||
|
border-bottom: 1px solid var(--nav-bg-alt);
|
||||||
|
}
|
||||||
|
|
||||||
|
.outremer-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: var(--nav-surface);
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.outremer-header:hover {
|
||||||
|
background: var(--nav-bg-alt);
|
||||||
|
}
|
||||||
|
|
||||||
|
.outremer-title {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--nav-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.outremer-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.outremer-count-badge {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--nav-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.outremer-chevron {
|
||||||
|
color: var(--nav-text-muted);
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.outremer-chevron--open {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.outremer-map-container {
|
||||||
|
height: 220px;
|
||||||
|
border-top: 1px solid var(--nav-bg-alt);
|
||||||
|
}
|
||||||
|
|
||||||
|
.outremer-map {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
11
components/TagBadge.vue
Normal file
11
components/TagBadge.vue
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<template>
|
||||||
|
<span
|
||||||
|
class="inline-block px-2 py-0.5 rounded-full text-xs font-medium bg-sage-100 text-sage-700 border border-sage-200 cursor-pointer hover:bg-sage-200 transition-colors"
|
||||||
|
@click.prevent="$emit('click', tag)"
|
||||||
|
>{{ tag }}</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{ tag: string }>()
|
||||||
|
defineEmits<{ click: [tag: string] }>()
|
||||||
|
</script>
|
||||||
162
components/TopSearchBar.vue
Normal file
162
components/TopSearchBar.vue
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
<template>
|
||||||
|
<!-- Barre de recherche animée : compacte par défaut, s'étend au focus -->
|
||||||
|
<div class="top-search-wrapper" :class="{ expanded: isFocused || hasValue }">
|
||||||
|
<label class="top-search-label" aria-label="Rechercher une organisation">
|
||||||
|
<!-- Icône loupe -->
|
||||||
|
<svg
|
||||||
|
class="top-search-icon"
|
||||||
|
width="16" height="16" viewBox="0 0 24 24"
|
||||||
|
fill="none" stroke="currentColor" stroke-width="2"
|
||||||
|
stroke-linecap="round" stroke-linejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<circle cx="11" cy="11" r="8"/>
|
||||||
|
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
|
||||||
|
</svg>
|
||||||
|
<!-- Input texte -->
|
||||||
|
<input
|
||||||
|
ref="inputEl"
|
||||||
|
v-model="localValue"
|
||||||
|
type="search"
|
||||||
|
placeholder="Rechercher une organisation…"
|
||||||
|
class="top-search-input"
|
||||||
|
autocomplete="off"
|
||||||
|
@focus="isFocused = true"
|
||||||
|
@blur="isFocused = false"
|
||||||
|
@input="onInput"
|
||||||
|
/>
|
||||||
|
<!-- Bouton clear -->
|
||||||
|
<button
|
||||||
|
v-if="hasValue"
|
||||||
|
type="button"
|
||||||
|
class="top-search-clear"
|
||||||
|
aria-label="Effacer la recherche"
|
||||||
|
@click.stop="clear"
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18"/>
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: string]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const inputEl = ref<HTMLInputElement | null>(null)
|
||||||
|
const isFocused = ref(false)
|
||||||
|
const localValue = ref(props.modelValue)
|
||||||
|
|
||||||
|
watch(() => props.modelValue, (v) => { localValue.value = v })
|
||||||
|
|
||||||
|
const hasValue = computed(() => localValue.value.length > 0)
|
||||||
|
|
||||||
|
function onInput() {
|
||||||
|
emit('update:modelValue', localValue.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function clear() {
|
||||||
|
localValue.value = ''
|
||||||
|
emit('update:modelValue', '')
|
||||||
|
nextTick(() => inputEl.value?.focus())
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.top-search-wrapper {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
position: relative;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-search-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
border: 1.5px solid var(--nav-bg-alt);
|
||||||
|
border-radius: 20px;
|
||||||
|
background: var(--nav-bg);
|
||||||
|
padding: 5px 10px;
|
||||||
|
cursor: text;
|
||||||
|
width: 44px;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: width 0.25s ease, border-color 0.2s, background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-search-wrapper.expanded .top-search-label {
|
||||||
|
width: 280px;
|
||||||
|
border-color: var(--nav-primary);
|
||||||
|
background: var(--nav-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.top-search-wrapper.expanded .top-search-label {
|
||||||
|
width: calc(100vw - 80px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-search-icon {
|
||||||
|
color: var(--nav-text-muted);
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-search-wrapper.expanded .top-search-icon {
|
||||||
|
color: var(--nav-primary-solid);
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-search-input {
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--nav-text);
|
||||||
|
font-size: 13px;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-search-wrapper.expanded .top-search-input {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-search-input::placeholder {
|
||||||
|
color: var(--nav-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Masquer le bouton clear natif des navigateurs */
|
||||||
|
.top-search-input::-webkit-search-cancel-button {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-search-clear {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--nav-text-muted);
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 1px;
|
||||||
|
border-radius: 50%;
|
||||||
|
transition: color 0.15s, background 0.15s;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-search-clear:hover {
|
||||||
|
color: var(--nav-text);
|
||||||
|
background: var(--nav-bg-alt);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
21
components/TypeBadge.vue
Normal file
21
components/TypeBadge.vue
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<template>
|
||||||
|
<span :class="['inline-block px-2.5 py-0.5 rounded-full text-xs font-semibold uppercase tracking-wide', colorClass]">
|
||||||
|
{{ type }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const props = defineProps<{ type: string }>()
|
||||||
|
|
||||||
|
const colors: Record<string, string> = {
|
||||||
|
association: 'bg-blue-100 text-blue-700',
|
||||||
|
syndicat: 'bg-orange-100 text-orange-700',
|
||||||
|
institution: 'bg-purple-100 text-purple-700',
|
||||||
|
reseau: 'bg-teal-100 text-teal-700',
|
||||||
|
collectif: 'bg-pink-100 text-pink-700',
|
||||||
|
ecole: 'bg-yellow-100 text-yellow-700',
|
||||||
|
media: 'bg-red-100 text-red-700',
|
||||||
|
}
|
||||||
|
|
||||||
|
const colorClass = computed(() => colors[props.type] ?? 'bg-gray-100 text-gray-700')
|
||||||
|
</script>
|
||||||
69
content-a-propos.md
Normal file
69
content-a-propos.md
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
# Contenu — page à propos (aep.trans-former.fr/a-propos)
|
||||||
|
|
||||||
|
Rédigé 2026-04-25, session ATIS Business.
|
||||||
|
Textes prêts à coller dans `pages/a-propos.vue`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Section 1 — Architecture d'Écologie Politique (`mission-text`)
|
||||||
|
|
||||||
|
L'architecture est l'une des professions les plus complexes qui soit ; elle croise droit, technique, esthétique, économie, social, écologie - tout à la fois, tout simultanément, souvent sans filet. Paradoxalement, c'est aussi l'une des moins structurées sur le plan de l'entraide : peu de transmission horizontale, beaucoup d'isolement, une culture du chacun-pour-soi héritée d'une formation qui prépare à la compétition plus qu'à la coopération. On sort de l'école seul.e. On s'installe seul.e. On réinvente ce que d'autres ont déjà traversé.
|
||||||
|
|
||||||
|
Cette carte est née de cette frustration - et de cette conviction : les ressources existent, les gens qui ont réussi à sortir la tête de l'eau aussi. L'enjeu, c'est de les documenter, de les rendre accessibles, de les ajuster en temps réel grâce aux retours de la communauté. Pas un catalogue figé ; un commun vivant, au service de ceux et celles qui cherchent à faire évoluer leur pratique vers quelque chose de plus épanouissant, mieux rémunéré, au service de la société - et qui prend soin de la santé, la nôtre et celle des gens pour qui nous construisons.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Section 2 — Souveraineté technique (`section-intro`)
|
||||||
|
|
||||||
|
Ce site a été construit en une journée par quelqu'un qui n'est pas développeur. C'est délibéré : l'IA permet aujourd'hui de structurer des outils d'émancipation collective sans expertise technique préalable, à condition de choisir des briques sobres et souveraines. L'architecture est simple ; le chatbot tourne sur un modèle léger, peu gourmand en énergie. Pas de dépendance aux infrastructures américaines, pas de collecte de données ni de logique de monétisation.
|
||||||
|
|
||||||
|
*(Badges existants conservés tels quels : Mistral AI / Hetzner / Zéro cookie US)*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Section 3 — Gouvernance (`section-text`)
|
||||||
|
|
||||||
|
Il m'a fallu cinq ans après les études pour sortir la tête de l'eau et commencer à avoir une pratique un peu plus alignée avec qui je suis. Cinq ans de chemin solitaire, à chercher des ressources qui n'existaient pas - ou qui existaient quelque part, mais que personne ne partageait. Ce qui m'a le plus sidéré, c'est le silence des anciens : pas par malveillance, mais parce qu'eux aussi étaient dans leur galère, la tête baissée, à serrer les dents. Atomisé·es, chacun·e dans son monde. Cette carte est née de l'envie que ça change.
|
||||||
|
|
||||||
|
Architecture d'Écologie Politique est un collectif en train de se monter - ouvert à toustes, à gouvernance horizontale, sans hiérarchie de droit. Les décisions se prennent en transparence. Si tu veux participer, contribuer, ou simplement suivre ce qui se construit : tu es la bienvenu.e.
|
||||||
|
|
||||||
|
Contact : [contact@trans-former.fr](mailto:contact@trans-former.fr)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Section 4 — Soutenir le projet (`section-text`)
|
||||||
|
|
||||||
|
Ce projet est participatif, libre, open source. Il existe parce que la communauté l'utilise - et il ne peut continuer à exister que si la communauté le soutient.
|
||||||
|
|
||||||
|
Le financer, c'est soutenir la structuration d'un écosystème d'entraide architecturale qui, aujourd'hui, est assez défaillant. Si toi aussi tu as manqué de ressources en chemin, si tu as dû réinventer seul.e ce que d'autres avaient déjà compris, si tu as cherché un filet et ne l'as pas trouvé - tu sais exactement pourquoi ça compte. Ce que tu mets dans la cagnotte ne va pas dans une boîte ; ça va dans un outil qui permet que la prochaine génération d'architectes ait ce que nous n'avons pas eu : un réseau d'entraide qui fonctionne, au service de pratiques plus épanouissantes, plus politiques, plus écologiques.
|
||||||
|
|
||||||
|
*(Bouton Liberapay + iframe existants conservés)*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sections non couvertes (à traiter dans une session dédiée)
|
||||||
|
|
||||||
|
**Section 5 — Transparence IA** (`section-text` ligne ~109)
|
||||||
|
Placeholder : "Pourquoi afficher les coûts IA en clair. Ce que ça dit du projet."
|
||||||
|
→ Sujet : la transparence des coûts comme acte politique (pas juste technique).
|
||||||
|
|
||||||
|
**Section 6 — Contribuer** (`section-text` ligne ~139)
|
||||||
|
Placeholder : "Comment rejoindre le projet, ce qu'on peut apporter."
|
||||||
|
→ Sujet : qui peut contribuer, quoi, comment - ton d'invitation sans friction.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Critique interne (à lire avant de coller)
|
||||||
|
|
||||||
|
**Ce qui tient :**
|
||||||
|
- Entrée Section 1 : le paradoxe complexité/isolement est bien posé, sans thèse abstraite.
|
||||||
|
- Section 4 "Soutenir" : l'ancrage "si toi aussi tu as manqué de ressources" est l'accroche la plus forte du texte.
|
||||||
|
- Section 2 : l'argument politique (une journée / pas développeur) est juste - ne pas le diluer.
|
||||||
|
|
||||||
|
**Corrections appliquées :**
|
||||||
|
- "Et pourtant, paradoxalement" (IA) → "Paradoxalement" seul, après point.
|
||||||
|
- Triple "aucune" en série (triptyque mécanique) → cassé en "Pas de... pas de..." sans répétition ternaire.
|
||||||
|
- Tirets longs (—) remplacés partout par tiret du 6 (-) ou point-virgule (;).
|
||||||
|
|
||||||
|
**Ce que toi seul peux écrire (si tu veux renforcer) :**
|
||||||
|
- Section 3 Gouvernance : une phrase sur pourquoi TU t'y es mis, personnellement - le texte est actuellement factuel mais manque d'une voix. Exemple : "J'ai passé ma première année d'installation à chercher ces ressources sans les trouver. Cette carte, c'est ce que j'aurais aimé avoir."
|
||||||
62
deploy.sh
Normal file
62
deploy.sh
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# deploy.sh — Deploiement AEP vers VPS Hetzner
|
||||||
|
# Usage : ./deploy.sh [--check-only]
|
||||||
|
# Pre-requis : build Nuxt termine (.output/ present), SSH alias vps-hetzner configure
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
TIMESTAMP=$(date '+%Y-%m-%d %H:%M:%S')
|
||||||
|
VPS="vps-hetzner"
|
||||||
|
REMOTE_DIR="/opt/aep"
|
||||||
|
SERVICE="aep"
|
||||||
|
LOCAL_ENV=".env.production"
|
||||||
|
REMOTE_ENV="$REMOTE_DIR/.env"
|
||||||
|
|
||||||
|
log() { echo "[$TIMESTAMP] $1"; }
|
||||||
|
|
||||||
|
log "=== Deploiement AEP ==="
|
||||||
|
|
||||||
|
# Garde-fou 1 : .output/ doit exister
|
||||||
|
if [ ! -d ".output" ]; then
|
||||||
|
log "ERREUR : dossier .output/ introuvable. Lancer 'npm run build' d'abord."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Garde-fou 2 : diff .env.production vs .env VPS
|
||||||
|
log "Verification des variables d'environnement..."
|
||||||
|
REMOTE_ENV_CONTENT=$(ssh -o ConnectTimeout=10 -o BatchMode=yes "$VPS" "cat $REMOTE_ENV 2>/dev/null || echo ''")
|
||||||
|
LOCAL_ENV_CONTENT=$(cat "$LOCAL_ENV" 2>/dev/null || echo "")
|
||||||
|
|
||||||
|
if [ "$LOCAL_ENV_CONTENT" != "$REMOTE_ENV_CONTENT" ]; then
|
||||||
|
log "AVERTISSEMENT : .env.production local != .env VPS"
|
||||||
|
log " --- Local ---"
|
||||||
|
echo "$LOCAL_ENV_CONTENT" | sed 's/TOKEN=.*/TOKEN=***/' | sed 's/^/ /'
|
||||||
|
log " --- VPS ---"
|
||||||
|
echo "$REMOTE_ENV_CONTENT" | sed 's/TOKEN=.*/TOKEN=***/' | sed 's/^/ /'
|
||||||
|
read -p "Continuer malgre la difference ? [y/N] " CONFIRM
|
||||||
|
[ "$CONFIRM" = "y" ] || { log "Deploiement annule."; exit 1; }
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$1" = "--check-only" ]; then
|
||||||
|
log "Mode check-only - aucun deploiement effectue."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Upload .output/ vers VPS via tar (rsync indisponible sous Windows)
|
||||||
|
log "Upload .output/ vers $VPS:$REMOTE_DIR..."
|
||||||
|
tar -czf - .output | ssh -o ConnectTimeout=60 -o BatchMode=yes "$VPS" \
|
||||||
|
"mkdir -p $REMOTE_DIR && cd $REMOTE_DIR && tar -xzf -"
|
||||||
|
log "Upload termine."
|
||||||
|
|
||||||
|
# Redemarrage du service
|
||||||
|
log "Redemarrage du service $SERVICE..."
|
||||||
|
ssh -o ConnectTimeout=10 -o BatchMode=yes "$VPS" \
|
||||||
|
"systemctl restart $SERVICE && sleep 2 && systemctl is-active $SERVICE"
|
||||||
|
log "Service $SERVICE actif."
|
||||||
|
|
||||||
|
# Verification sante
|
||||||
|
log "Verification sante (port 3333)..."
|
||||||
|
ssh -o ConnectTimeout=10 -o BatchMode=yes "$VPS" \
|
||||||
|
"curl -s -o /dev/null -w 'HTTP %{http_code}' http://localhost:3333/ || echo 'ERREUR acces port 3333'"
|
||||||
|
|
||||||
|
log "=== Deploiement termine : $TIMESTAMP ==="
|
||||||
7
deploy/systemd/nav-ratelimit-purge.service
Normal file
7
deploy/systemd/nav-ratelimit-purge.service
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Purge des fichiers rate-limit AEP anciens
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
ExecStartPre=/bin/mkdir -p /tmp/nav-ratelimit
|
||||||
|
ExecStart=/usr/bin/find /tmp/nav-ratelimit/ -type f -mtime +1 -delete
|
||||||
9
deploy/systemd/nav-ratelimit-purge.timer
Normal file
9
deploy/systemd/nav-ratelimit-purge.timer
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Timer quotidien purge rate-limit AEP
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnCalendar=*-*-* 03:00:00 UTC
|
||||||
|
Persistent=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
33
nuxt.config.ts
Normal file
33
nuxt.config.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
export default defineNuxtConfig({
|
||||||
|
modules: ['@nuxtjs/tailwindcss'],
|
||||||
|
css: ['~/assets/css/main.css'],
|
||||||
|
|
||||||
|
runtimeConfig: {
|
||||||
|
nocodbUrl: process.env.NOCODB_URL,
|
||||||
|
nocodbToken: process.env.NOCODB_TOKEN,
|
||||||
|
nocodbBase: process.env.NOCODB_BASE || process.env.NOCODB_BASE_ID || 'pipilvsi7dibo80',
|
||||||
|
orgTableId: process.env.ORG_TABLE_ID || process.env.NOCODB_TABLE_ORGAS || 'm08t7g5v4wch6wb',
|
||||||
|
avisTableId: process.env.AVIS_TABLE_ID,
|
||||||
|
commentTableId: process.env.COMMENT_TABLE_ID || process.env.AVIS_TABLE_ID,
|
||||||
|
statsTableId: process.env.STATS_TABLE_ID || 'mbbq7n47ixy19mc',
|
||||||
|
mistralApiKey: process.env.MISTRAL_API_KEY,
|
||||||
|
redisUrl: process.env.REDIS_URL || 'redis://127.0.0.1:6379',
|
||||||
|
resendApiKey: process.env.RESEND_API_KEY,
|
||||||
|
emailJules: process.env.EMAIL_JULES || 'jules@trans-former.fr',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Leaflet ne fonctionne pas en SSR — forcer le rendu côté client
|
||||||
|
ssr: true,
|
||||||
|
|
||||||
|
vite: {
|
||||||
|
optimizeDeps: {
|
||||||
|
include: ['leaflet', 'leaflet.markercluster'],
|
||||||
|
},
|
||||||
|
// Éviter l'import SSR de Leaflet qui utilise window
|
||||||
|
ssr: {
|
||||||
|
noExternal: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
compatibilityDate: '2024-11-01',
|
||||||
|
})
|
||||||
11840
package-lock.json
generated
Normal file
11840
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
25
package.json
Normal file
25
package.json
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"name": "nav-carte",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"build": "nuxt build",
|
||||||
|
"dev": "nuxt dev",
|
||||||
|
"generate": "nuxt generate",
|
||||||
|
"preview": "nuxt preview",
|
||||||
|
"postinstall": "nuxt prepare"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@headlessui/vue": "^1.7.23",
|
||||||
|
"@types/leaflet": "^1.9.21",
|
||||||
|
"@types/leaflet.markercluster": "^1.5.6",
|
||||||
|
"ioredis": "^5.3.2",
|
||||||
|
"leaflet": "^1.9.4",
|
||||||
|
"leaflet.markercluster": "^1.5.3",
|
||||||
|
"nuxt": "^3.15.0",
|
||||||
|
"zod": "^3.22.4"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@nuxtjs/tailwindcss": "^6.12.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
534
pages/a-propos.vue
Normal file
534
pages/a-propos.vue
Normal file
@@ -0,0 +1,534 @@
|
|||||||
|
<template>
|
||||||
|
<div class="apropos-page">
|
||||||
|
<div class="apropos-inner">
|
||||||
|
|
||||||
|
<!-- Retour -->
|
||||||
|
<NuxtLink to="/" class="back-link">
|
||||||
|
← Retour à la carte
|
||||||
|
</NuxtLink>
|
||||||
|
|
||||||
|
<!-- ══════════════════════════════════════════════════════════
|
||||||
|
SECTION 1 - Mission AEP
|
||||||
|
══════════════════════════════════════════════════════════ -->
|
||||||
|
<!-- TODO Jules : Écrire le pitch (~100 mots) - qui est AEP, pour qui, pourquoi, quelle promesse -->
|
||||||
|
<section class="section-mission">
|
||||||
|
<h1>Architecture d'Écologie Politique</h1>
|
||||||
|
<p class="mission-text">
|
||||||
|
L'architecture est l'une des professions les plus complexes qui soit ; elle croise droit, technique, esthétique, économie, social, écologie - tout à la fois, tout simultanément, souvent sans filet. Paradoxalement, c'est aussi l'une des moins structurées sur le plan de l'entraide : peu de transmission horizontale, beaucoup d'isolement, une culture du chacun-pour-soi héritée d'une formation qui prépare à la compétition plus qu'à la coopération. On sort de l'école seul.e. On s'installe seul.e. On réinvente ce que d'autres ont déjà traversé.
|
||||||
|
</p>
|
||||||
|
<p class="mission-text">
|
||||||
|
Cette carte est née de cette frustration - et de cette conviction : les ressources existent, les gens qui ont réussi à sortir la tête de l'eau aussi. L'enjeu, c'est de les documenter, de les rendre accessibles, de les ajuster en temps réel grâce aux retours de la communauté. Pas un catalogue figé ; un commun vivant, au service de ceux et celles qui cherchent à faire évoluer leur pratique vers quelque chose de plus épanouissant, mieux rémunéré, au service de la société - et qui prend soin de la santé, la nôtre et celle des gens pour qui nous construisons.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ══════════════════════════════════════════════════════════
|
||||||
|
SECTION 2 - Souveraineté technique
|
||||||
|
══════════════════════════════════════════════════════════ -->
|
||||||
|
<!-- TODO Jules : Écrire 1-2 phrases d'intro sur la posture technique (pourquoi c'est important pour toi) -->
|
||||||
|
<section class="section">
|
||||||
|
<h2>Infrastructure souveraine</h2>
|
||||||
|
<p class="section-intro">
|
||||||
|
Ce site tourne sur un VPS - un serveur privé loué en Europe - construit entièrement avec des logiciels open source, quasi gratuits. Pas de services Google, pas d'Amazon, pas de Microsoft dans la chaîne. C'est un choix délibéré : contrôler son infrastructure, c'est contrôler ses données.
|
||||||
|
</p>
|
||||||
|
<div class="badges-row">
|
||||||
|
<div class="badge">
|
||||||
|
<span class="badge-icon" aria-hidden="true">
|
||||||
|
<!-- Mistral logo placeholder -->
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 8v4l3 3"/></svg>
|
||||||
|
</span>
|
||||||
|
<span class="badge-label">IA souveraine</span>
|
||||||
|
<span class="badge-detail">Mistral AI, Paris - conforme RGPD ; pas de collecte ni de profiling ; aucun transfert hors UE</span>
|
||||||
|
</div>
|
||||||
|
<div class="badge">
|
||||||
|
<span class="badge-icon" aria-hidden="true">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>
|
||||||
|
</span>
|
||||||
|
<span class="badge-label">Hébergé en Europe</span>
|
||||||
|
<span class="badge-detail">Hetzner, Allemagne</span>
|
||||||
|
</div>
|
||||||
|
<div class="badge">
|
||||||
|
<span class="badge-icon" aria-hidden="true">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
|
||||||
|
</span>
|
||||||
|
<span class="badge-label">Zéro cookie US</span>
|
||||||
|
<span class="badge-detail">Pas de Google Analytics, pas de Meta Pixel, pas de tracker tiers ; on ne sait rien de toi</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ══════════════════════════════════════════════════════════
|
||||||
|
SECTION 3 - Gouvernance
|
||||||
|
══════════════════════════════════════════════════════════ -->
|
||||||
|
<!-- TODO Jules : Écrire qui porte ce projet, le collectif, la posture politique, le lien Liberapay -->
|
||||||
|
<section class="section">
|
||||||
|
<h2>Gouvernance</h2>
|
||||||
|
<p class="section-text">
|
||||||
|
Architecture d'Écologie Politique est un collectif en train de se monter - ouvert à toustes, à gouvernance partagée. Les décisions se prennent en transparence. Si tu veux participer, contribuer, ou simplement suivre ce qui se construit : tu es la bienvenu·e. Contact : <a href="mailto:contact@trans-former.fr" class="text-link">contact@trans-former.fr</a>
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ══════════════════════════════════════════════════════════
|
||||||
|
SECTION 4 - Transparence Liberapay
|
||||||
|
══════════════════════════════════════════════════════════ -->
|
||||||
|
<!-- TODO Jules : Écrire pourquoi Liberapay (pas Patreon, pas Ko-fi), le modèle de financement voulu -->
|
||||||
|
<section class="section section-liberapay">
|
||||||
|
<h2>Soutenir le projet</h2>
|
||||||
|
<p class="section-text">
|
||||||
|
Ce projet est participatif, libre, open source. Il existe parce que la communauté l'utilise - et il ne peut continuer à exister que si la communauté le soutient.
|
||||||
|
</p>
|
||||||
|
<p class="section-text">
|
||||||
|
Le financer, c'est soutenir la structuration d'un écosystème d'entraide architecturale qui, aujourd'hui, est assez défaillant. Si toi aussi tu as manqué de ressources en chemin, si tu as dû réinventer seul·e ce que d'autres avaient déjà compris, si tu as cherché un filet et ne l'as pas trouvé - tu sais exactement pourquoi ça compte. Ce que tu mets dans la cagnotte ne va pas dans une boîte ; ça va dans un outil qui permet que la prochaine génération d'architectes ait ce que nous n'avons pas eu : un réseau d'entraide qui fonctionne, au service de pratiques plus épanouissantes, plus politiques, plus écologiques.
|
||||||
|
</p>
|
||||||
|
<p class="liberapay-note">
|
||||||
|
Liberapay est une plateforme open source EU (ASBL belge non-lucrative), 0% de frais.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Bouton CTA -->
|
||||||
|
<a
|
||||||
|
href="https://liberapay.com/trans-former.fr/donate"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="btn-liberapay"
|
||||||
|
>
|
||||||
|
Soutenir sur Liberapay →
|
||||||
|
</a>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ══════════════════════════════════════════════════════════
|
||||||
|
SECTION 5 - Transparence IA
|
||||||
|
══════════════════════════════════════════════════════════ -->
|
||||||
|
<!-- TODO Jules : Écrire la posture sur l'IA - pourquoi ces modèles, pourquoi la transparence, ce que ça signifie politiquement -->
|
||||||
|
<section id="ia" class="section">
|
||||||
|
<h2>Transparence IA</h2>
|
||||||
|
<p class="section-text">
|
||||||
|
Ce site a été construit grâce à l'IA, par quelqu'un qui n'est pas développeur. C'est une manière de subvertir les outils des Big Tech pour en faire quelque chose de communautaire et de souverain : une infrastructure locale, des données qu'on contrôle, un commun qui n'appartient à personne en particulier.
|
||||||
|
</p>
|
||||||
|
<p class="section-text">
|
||||||
|
L'IA est un outil. La question, c'est au service de quoi. Oui, elle est énergivore - et il faut l'assumer. Mais si c'est au service de l'émancipation, du collectif, de transformer nos infrastructures sociétales, alors on dit oui - en sachant que c'est une étape transitoire. L'infrastructure de l'IA elle-même peut évoluer : devenir plus locale, plus sobre, plus souveraine. C'est dans cette direction qu'on regarde.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="ia-grid">
|
||||||
|
<div class="ia-card">
|
||||||
|
<div class="ia-card-label">Enrichissement des fiches</div>
|
||||||
|
<div class="ia-card-model">Mistral Nemo</div>
|
||||||
|
<div class="ia-card-cost">~0,00003 €/fiche</div>
|
||||||
|
</div>
|
||||||
|
<div class="ia-card">
|
||||||
|
<div class="ia-card-label">Chatbot de recherche</div>
|
||||||
|
<div class="ia-card-model">Mistral Small</div>
|
||||||
|
<div class="ia-card-cost">~0,003 €/requête</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul class="ia-details">
|
||||||
|
<li>10 € / mois investis au départ par le collectif - vos dons constituent la réserve pour faire vivre le projet et financer ses recherches</li>
|
||||||
|
<li>Coût visible en direct dans le bandeau bas de la carte</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ══════════════════════════════════════════════════════════
|
||||||
|
SECTION 6 - Contribuer
|
||||||
|
══════════════════════════════════════════════════════════ -->
|
||||||
|
<!-- TODO Jules : Écrire l'invitation à contribuer - ton, posture, ce qu'on attend des contributeurs -->
|
||||||
|
<section class="section section-contribuer">
|
||||||
|
<h2>Contribuer</h2>
|
||||||
|
<p class="section-text">
|
||||||
|
Il n'y a pas de barre d'entrée ici. Une correction, une ressource à signaler, un retour sur ta propre expérience - c'est déjà participer à quelque chose qui grandit par accumulation de petits gestes.
|
||||||
|
</p>
|
||||||
|
<div class="contribuer-ctas">
|
||||||
|
<NuxtLink to="/contribuer" class="btn-primary">
|
||||||
|
Proposer une fiche →
|
||||||
|
</NuxtLink>
|
||||||
|
<a
|
||||||
|
href="https://trans-former.fr/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="btn-secondary"
|
||||||
|
>
|
||||||
|
Blog AEP
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="https://www.instagram.com/aep.politique/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="btn-secondary"
|
||||||
|
>
|
||||||
|
Instagram
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="https://liberapay.com/trans-former.fr/donate"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="btn-secondary"
|
||||||
|
>
|
||||||
|
Soutenir
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="mailto:contact@trans-former.fr"
|
||||||
|
class="btn-secondary"
|
||||||
|
>
|
||||||
|
Contact
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ══════════════════════════════════════════════════════════
|
||||||
|
SECTION 7 - Open Source
|
||||||
|
══════════════════════════════════════════════════════════ -->
|
||||||
|
<section class="section section-opensource">
|
||||||
|
<h2>Code source ouvert</h2>
|
||||||
|
<p class="section-text">
|
||||||
|
Cette carte est un commun technique autant qu'un commun de ressources. Le code source est libre — tu peux le forker, l'adapter, le déployer pour ta propre cartographie d'entraide, ton territoire, ta filière.
|
||||||
|
</p>
|
||||||
|
<p class="section-text">
|
||||||
|
Une liste de ressources locales pour les artisans ? Un réseau d'entraide pour les soignants indépendants ? La structure est la même. L'IA d'enrichissement est la même. Il suffit de changer les données.
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
href="https://git.trans-former.fr/jules/nav-carte"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="btn-github"
|
||||||
|
>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0 0 24 12c0-6.63-5.37-12-12-12z"/></svg>
|
||||||
|
Code source — git.trans-former.fr →
|
||||||
|
</a>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
useHead({ title: 'À propos - AEP' })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* ── Layout ─────────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.apropos-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: var(--nav-bg);
|
||||||
|
padding: 1.5rem 1rem 5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.apropos-inner {
|
||||||
|
max-width: 720px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Retour ──────────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.back-link {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--nav-primary-solid);
|
||||||
|
opacity: 0.7;
|
||||||
|
text-decoration: none;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-link:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Sections ────────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.section-mission,
|
||||||
|
.section {
|
||||||
|
margin-bottom: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-mission h1 {
|
||||||
|
font-size: 1.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--nav-text);
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section h2 {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--nav-text);
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
border-bottom: 1px solid var(--nav-bg-alt);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mission-text,
|
||||||
|
.section-intro,
|
||||||
|
.section-text {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: var(--nav-text);
|
||||||
|
line-height: 1.65;
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-intro {
|
||||||
|
color: var(--nav-text-muted);
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-link {
|
||||||
|
color: var(--nav-primary-solid);
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-link:hover {
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Badges souveraineté ──────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.badges-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.625rem 0.875rem;
|
||||||
|
background: var(--nav-bg-alt);
|
||||||
|
border-radius: 9999px;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 160px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 560px) {
|
||||||
|
.badge {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
flex: none;
|
||||||
|
min-width: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-icon {
|
||||||
|
color: var(--nav-text-muted);
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-label {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--nav-text);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-detail {
|
||||||
|
font-size: 0.775rem;
|
||||||
|
color: var(--nav-text-muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 559px) {
|
||||||
|
.badge {
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
}
|
||||||
|
.badge-label {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Liberapay ───────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.liberapay-note {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--nav-text-muted);
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-liberapay {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding: 0.625rem 1.25rem;
|
||||||
|
background: var(--nav-primary);
|
||||||
|
color: var(--nav-text-on-primary);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-liberapay:hover {
|
||||||
|
background: rgba(26, 34, 56, 0.75);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── IA grid ─────────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.ia-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.ia-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ia-card {
|
||||||
|
background: var(--nav-surface);
|
||||||
|
border: 1px solid rgba(26, 34, 56, 0.1);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 1rem 1.125rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ia-card-label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--nav-text-muted);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ia-card-model {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--nav-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ia-card-cost {
|
||||||
|
font-size: 0.825rem;
|
||||||
|
color: var(--nav-text-muted);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ia-details {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ia-details li {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--nav-text-muted);
|
||||||
|
padding-left: 1rem;
|
||||||
|
position: relative;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ia-details li::before {
|
||||||
|
content: '—';
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
color: var(--nav-text-muted);
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── CTAs contribuer ─────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.contribuer-ctas {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
background: var(--nav-accent);
|
||||||
|
color: var(--nav-text);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.75rem 1.25rem;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--nav-text-muted);
|
||||||
|
border: 1px solid rgba(26, 34, 56, 0.2);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: border-color 0.15s, color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
border-color: var(--nav-primary-solid);
|
||||||
|
color: var(--nav-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── GitHub open source ──────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.section-opensource {
|
||||||
|
border-top: 1px solid var(--nav-bg-alt);
|
||||||
|
padding-top: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-github {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.625rem 1.25rem;
|
||||||
|
background: var(--nav-surface);
|
||||||
|
border: 1px solid rgba(26, 34, 56, 0.2);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--nav-text);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: border-color 0.15s, background 0.15s;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-github:hover {
|
||||||
|
border-color: var(--nav-primary-solid);
|
||||||
|
background: var(--nav-bg-alt);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Responsive général ──────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.apropos-page {
|
||||||
|
padding: 1rem 0.75rem 4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-mission h1 {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contribuer-ctas {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary,
|
||||||
|
.btn-secondary {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
477
pages/a-propos.vue.tmp.47800.1777123068982
Normal file
477
pages/a-propos.vue.tmp.47800.1777123068982
Normal file
@@ -0,0 +1,477 @@
|
|||||||
|
<template>
|
||||||
|
<div class="apropos-page">
|
||||||
|
<div class="apropos-inner">
|
||||||
|
|
||||||
|
<!-- Retour -->
|
||||||
|
<NuxtLink to="/" class="back-link">
|
||||||
|
← Retour à la carte
|
||||||
|
</NuxtLink>
|
||||||
|
|
||||||
|
<!-- ══════════════════════════════════════════════════════════
|
||||||
|
SECTION 1 — Mission AEP
|
||||||
|
══════════════════════════════════════════════════════════ -->
|
||||||
|
<!-- TODO Jules : Écrire le pitch (~100 mots) — qui est AEP, pour qui, pourquoi, quelle promesse -->
|
||||||
|
<section class="section-mission">
|
||||||
|
<h1>Architecture d'Écologie Politique</h1>
|
||||||
|
<p class="mission-text">
|
||||||
|
L'architecture est l'une des professions les plus complexes qui soit ; elle croise droit, technique, esthétique, économie, social, écologie - tout à la fois, tout simultanément, souvent sans filet. Paradoxalement, c'est aussi l'une des moins structurées sur le plan de l'entraide : peu de transmission horizontale, beaucoup d'isolement, une culture du chacun-pour-soi héritée d'une formation qui prépare à la compétition plus qu'à la coopération. On sort de l'école seul.e. On s'installe seul.e. On réinvente ce que d'autres ont déjà traversé.
|
||||||
|
</p>
|
||||||
|
<p class="mission-text">
|
||||||
|
Cette carte est née de cette frustration - et de cette conviction : les ressources existent, les gens qui ont réussi à sortir la tête de l'eau aussi. L'enjeu, c'est de les documenter, de les rendre accessibles, de les ajuster en temps réel grâce aux retours de la communauté. Pas un catalogue figé ; un commun vivant, au service de ceux et celles qui cherchent à faire évoluer leur pratique vers quelque chose de plus épanouissant, mieux rémunéré, au service de la société - et qui prend soin de la santé, la nôtre et celle des gens pour qui nous construisons.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ══════════════════════════════════════════════════════════
|
||||||
|
SECTION 2 — Souveraineté technique
|
||||||
|
══════════════════════════════════════════════════════════ -->
|
||||||
|
<!-- TODO Jules : Écrire 1-2 phrases d'intro sur la posture technique (pourquoi c'est important pour toi) -->
|
||||||
|
<section class="section">
|
||||||
|
<h2>Souveraineté technique</h2>
|
||||||
|
<p class="section-intro">
|
||||||
|
Ce site a été construit en une journée par quelqu'un qui n'est pas développeur. C'est délibéré : l'IA permet aujourd'hui de structurer des outils d'émancipation collective sans expertise technique préalable, à condition de choisir des briques sobres et souveraines. L'architecture est simple ; le chatbot tourne sur un modèle léger, peu gourmand en énergie. Pas de dépendance aux infrastructures américaines, pas de collecte de données ni de logique de monétisation.
|
||||||
|
</p>
|
||||||
|
<div class="badges-row">
|
||||||
|
<div class="badge">
|
||||||
|
<span class="badge-icon" aria-hidden="true">
|
||||||
|
<!-- Mistral logo placeholder -->
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 8v4l3 3"/></svg>
|
||||||
|
</span>
|
||||||
|
<span class="badge-label">IA souveraine</span>
|
||||||
|
<span class="badge-detail">Mistral AI, Paris</span>
|
||||||
|
</div>
|
||||||
|
<div class="badge">
|
||||||
|
<span class="badge-icon" aria-hidden="true">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>
|
||||||
|
</span>
|
||||||
|
<span class="badge-label">Hébergé en Europe</span>
|
||||||
|
<span class="badge-detail">Hetzner, Allemagne</span>
|
||||||
|
</div>
|
||||||
|
<div class="badge">
|
||||||
|
<span class="badge-icon" aria-hidden="true">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
|
||||||
|
</span>
|
||||||
|
<span class="badge-label">Zéro cookie US</span>
|
||||||
|
<span class="badge-detail">Pas de Google Analytics, pas de Meta Pixel, pas de tracker tiers</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ══════════════════════════════════════════════════════════
|
||||||
|
SECTION 3 — Gouvernance
|
||||||
|
══════════════════════════════════════════════════════════ -->
|
||||||
|
<!-- TODO Jules : Écrire qui porte ce projet, le collectif, la posture politique, le lien Liberapay -->
|
||||||
|
<section class="section">
|
||||||
|
<h2>Gouvernance</h2>
|
||||||
|
<p class="section-text">
|
||||||
|
Il m'a fallu cinq ans après les études pour sortir la tête de l'eau et commencer à avoir une pratique un peu plus alignée avec qui je suis. Cinq ans de chemin solitaire, à chercher des ressources qui n'existaient pas - ou qui existaient quelque part, mais que personne ne partageait. Ce qui m'a le plus sidéré, c'est le silence des anciens : pas par malveillance, mais parce qu'eux aussi étaient dans leur galère, la tête baissée, à serrer les dents. Atomisé·es, chacun·e dans son monde. Cette carte est née de l'envie que ça change.
|
||||||
|
</p>
|
||||||
|
<p class="section-text">
|
||||||
|
Architecture d'Écologie Politique est un collectif en train de se monter - ouvert à toustes, à gouvernance horizontale, sans hiérarchie de droit. Les décisions se prennent en transparence. Si tu veux participer, contribuer, ou simplement suivre ce qui se construit : tu es la bienvenu·e. Contact : <a href="mailto:contact@trans-former.fr" class="text-link">contact@trans-former.fr</a>
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ══════════════════════════════════════════════════════════
|
||||||
|
SECTION 4 — Transparence Liberapay
|
||||||
|
══════════════════════════════════════════════════════════ -->
|
||||||
|
<!-- TODO Jules : Écrire pourquoi Liberapay (pas Patreon, pas Ko-fi), le modèle de financement voulu -->
|
||||||
|
<section class="section section-liberapay">
|
||||||
|
<h2>Soutenir le projet</h2>
|
||||||
|
<p class="section-text">
|
||||||
|
Ce projet est participatif, libre, open source. Il existe parce que la communauté l'utilise - et il ne peut continuer à exister que si la communauté le soutient.
|
||||||
|
</p>
|
||||||
|
<p class="section-text">
|
||||||
|
Le financer, c'est soutenir la structuration d'un écosystème d'entraide architecturale qui, aujourd'hui, est assez défaillant. Si toi aussi tu as manqué de ressources en chemin, si tu as dû réinventer seul·e ce que d'autres avaient déjà compris, si tu as cherché un filet et ne l'as pas trouvé - tu sais exactement pourquoi ça compte. Ce que tu mets dans la cagnotte ne va pas dans une boîte ; ça va dans un outil qui permet que la prochaine génération d'architectes ait ce que nous n'avons pas eu : un réseau d'entraide qui fonctionne, au service de pratiques plus épanouissantes, plus politiques, plus écologiques.
|
||||||
|
</p>
|
||||||
|
<p class="liberapay-note">
|
||||||
|
Liberapay est une plateforme open source EU (ASBL belge non-lucrative), 0% de frais.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Bouton CTA -->
|
||||||
|
<a
|
||||||
|
href="https://liberapay.com/trans-former.fr/donate"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="btn-liberapay"
|
||||||
|
>
|
||||||
|
Soutenir sur Liberapay →
|
||||||
|
</a>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ══════════════════════════════════════════════════════════
|
||||||
|
SECTION 5 — Transparence IA
|
||||||
|
══════════════════════════════════════════════════════════ -->
|
||||||
|
<!-- TODO Jules : Écrire la posture sur l'IA — pourquoi ces modèles, pourquoi la transparence, ce que ça signifie politiquement -->
|
||||||
|
<section id="ia" class="section">
|
||||||
|
<h2>Transparence IA</h2>
|
||||||
|
<p class="section-text">
|
||||||
|
Les Big Tech cachent leurs coûts parce qu'ils n'ont pas intérêt à ce que tu les voies. Afficher ce qu'on dépense ici, c'est un acte politique : sobriété choisie, modèles européens, rapport assumé aux outils — pas par vertu, mais par cohérence avec ce qu'on construit.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="ia-grid">
|
||||||
|
<div class="ia-card">
|
||||||
|
<div class="ia-card-label">Enrichissement des fiches</div>
|
||||||
|
<div class="ia-card-model">Mistral Nemo</div>
|
||||||
|
<div class="ia-card-cost">~0,00003 €/fiche</div>
|
||||||
|
</div>
|
||||||
|
<div class="ia-card">
|
||||||
|
<div class="ia-card-label">Chatbot de recherche</div>
|
||||||
|
<div class="ia-card-model">Mistral Small</div>
|
||||||
|
<div class="ia-card-cost">~0,003 €/requête</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul class="ia-details">
|
||||||
|
<li>Budget plafonné à 20 € / mois — coupure automatique si dépassement</li>
|
||||||
|
<li>Coût visible en direct dans le bandeau bas de la carte</li>
|
||||||
|
<li>Aucun modèle US, aucune donnée transmise hors UE</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ══════════════════════════════════════════════════════════
|
||||||
|
SECTION 6 — Contribuer
|
||||||
|
══════════════════════════════════════════════════════════ -->
|
||||||
|
<!-- TODO Jules : Écrire l'invitation à contribuer — ton, posture, ce qu'on attend des contributeurs -->
|
||||||
|
<section class="section section-contribuer">
|
||||||
|
<h2>Contribuer</h2>
|
||||||
|
<p class="section-text">
|
||||||
|
Il n'y a pas de barre d'entrée ici. Une correction, une ressource à signaler, un retour sur ta propre expérience — c'est déjà participer à quelque chose qui grandit par accumulation de petits gestes.
|
||||||
|
</p>
|
||||||
|
<div class="contribuer-ctas">
|
||||||
|
<NuxtLink to="/contribuer" class="btn-primary">
|
||||||
|
Proposer une fiche →
|
||||||
|
</NuxtLink>
|
||||||
|
<a
|
||||||
|
href="https://liberapay.com/trans-former.fr/donate"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="btn-secondary"
|
||||||
|
>
|
||||||
|
Soutenir financièrement
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="https://mail.trans-former.fr/subscription/form"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="btn-secondary"
|
||||||
|
>
|
||||||
|
S'inscrire à l'infolettre
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="mailto:contact@trans-former.fr"
|
||||||
|
class="btn-secondary"
|
||||||
|
>
|
||||||
|
Nous contacter
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
useHead({ title: 'À propos — AEP' })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* ── Layout ─────────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.apropos-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: var(--nav-bg);
|
||||||
|
padding: 1.5rem 1rem 5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.apropos-inner {
|
||||||
|
max-width: 720px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Retour ──────────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.back-link {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--nav-primary-solid);
|
||||||
|
opacity: 0.7;
|
||||||
|
text-decoration: none;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-link:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Sections ────────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.section-mission,
|
||||||
|
.section {
|
||||||
|
margin-bottom: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-mission h1 {
|
||||||
|
font-size: 1.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--nav-text);
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section h2 {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--nav-text);
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
border-bottom: 1px solid var(--nav-bg-alt);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mission-text,
|
||||||
|
.section-intro,
|
||||||
|
.section-text {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: var(--nav-text);
|
||||||
|
line-height: 1.65;
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-intro {
|
||||||
|
color: var(--nav-text-muted);
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-link {
|
||||||
|
color: var(--nav-primary-solid);
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-link:hover {
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Badges souveraineté ──────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.badges-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.625rem 0.875rem;
|
||||||
|
background: var(--nav-bg-alt);
|
||||||
|
border-radius: 9999px;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 160px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 560px) {
|
||||||
|
.badge {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
flex: none;
|
||||||
|
min-width: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-icon {
|
||||||
|
color: var(--nav-text-muted);
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-label {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--nav-text);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-detail {
|
||||||
|
font-size: 0.775rem;
|
||||||
|
color: var(--nav-text-muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 559px) {
|
||||||
|
.badge {
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
}
|
||||||
|
.badge-label {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Liberapay ───────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.liberapay-note {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--nav-text-muted);
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-liberapay {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding: 0.625rem 1.25rem;
|
||||||
|
background: var(--nav-primary);
|
||||||
|
color: var(--nav-text-on-primary);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-liberapay:hover {
|
||||||
|
background: rgba(26, 34, 56, 0.75);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── IA grid ─────────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.ia-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.ia-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ia-card {
|
||||||
|
background: var(--nav-surface);
|
||||||
|
border: 1px solid rgba(26, 34, 56, 0.1);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 1rem 1.125rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ia-card-label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--nav-text-muted);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ia-card-model {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--nav-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ia-card-cost {
|
||||||
|
font-size: 0.825rem;
|
||||||
|
color: var(--nav-text-muted);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ia-details {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ia-details li {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--nav-text-muted);
|
||||||
|
padding-left: 1rem;
|
||||||
|
position: relative;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ia-details li::before {
|
||||||
|
content: '—';
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
color: var(--nav-text-muted);
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── CTAs contribuer ─────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.contribuer-ctas {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
background: var(--nav-accent);
|
||||||
|
color: var(--nav-text);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.75rem 1.25rem;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--nav-text-muted);
|
||||||
|
border: 1px solid rgba(26, 34, 56, 0.2);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: border-color 0.15s, color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
border-color: var(--nav-primary-solid);
|
||||||
|
color: var(--nav-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Responsive général ──────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.apropos-page {
|
||||||
|
padding: 1rem 0.75rem 4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-mission h1 {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contribuer-ctas {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary,
|
||||||
|
.btn-secondary {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
534
pages/a-propos.vue.tmp.916.1777376899845
Normal file
534
pages/a-propos.vue.tmp.916.1777376899845
Normal file
@@ -0,0 +1,534 @@
|
|||||||
|
<template>
|
||||||
|
<div class="apropos-page">
|
||||||
|
<div class="apropos-inner">
|
||||||
|
|
||||||
|
<!-- Retour -->
|
||||||
|
<NuxtLink to="/" class="back-link">
|
||||||
|
← Retour à la carte
|
||||||
|
</NuxtLink>
|
||||||
|
|
||||||
|
<!-- ══════════════════════════════════════════════════════════
|
||||||
|
SECTION 1 - Mission AEP
|
||||||
|
══════════════════════════════════════════════════════════ -->
|
||||||
|
<!-- TODO Jules : Écrire le pitch (~100 mots) - qui est AEP, pour qui, pourquoi, quelle promesse -->
|
||||||
|
<section class="section-mission">
|
||||||
|
<h1>Architecture d'Écologie Politique</h1>
|
||||||
|
<p class="mission-text">
|
||||||
|
L'architecture est l'une des professions les plus complexes qui soit ; elle croise droit, technique, esthétique, économie, social, écologie - tout à la fois, tout simultanément, souvent sans filet. Paradoxalement, c'est aussi l'une des moins structurées sur le plan de l'entraide : peu de transmission horizontale, beaucoup d'isolement, une culture du chacun-pour-soi héritée d'une formation qui prépare à la compétition plus qu'à la coopération. On sort de l'école seul.e. On s'installe seul.e. On réinvente ce que d'autres ont déjà traversé.
|
||||||
|
</p>
|
||||||
|
<p class="mission-text">
|
||||||
|
Cette carte est née de cette frustration - et de cette conviction : les ressources existent, les gens qui ont réussi à sortir la tête de l'eau aussi. L'enjeu, c'est de les documenter, de les rendre accessibles, de les ajuster en temps réel grâce aux retours de la communauté. Pas un catalogue figé ; un commun vivant, au service de ceux et celles qui cherchent à faire évoluer leur pratique vers quelque chose de plus épanouissant, mieux rémunéré, au service de la société - et qui prend soin de la santé, la nôtre et celle des gens pour qui nous construisons.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ══════════════════════════════════════════════════════════
|
||||||
|
SECTION 2 - Souveraineté technique
|
||||||
|
══════════════════════════════════════════════════════════ -->
|
||||||
|
<!-- TODO Jules : Écrire 1-2 phrases d'intro sur la posture technique (pourquoi c'est important pour toi) -->
|
||||||
|
<section class="section">
|
||||||
|
<h2>Infrastructure souveraine</h2>
|
||||||
|
<p class="section-intro">
|
||||||
|
Ce site tourne sur un VPS - un serveur privé loué en Europe - construit entièrement avec des logiciels open source, quasi gratuits. Pas de services Google, pas d'Amazon, pas de Microsoft dans la chaîne. C'est un choix délibéré : contrôler son infrastructure, c'est contrôler ses données.
|
||||||
|
</p>
|
||||||
|
<div class="badges-row">
|
||||||
|
<div class="badge">
|
||||||
|
<span class="badge-icon" aria-hidden="true">
|
||||||
|
<!-- Mistral logo placeholder -->
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 8v4l3 3"/></svg>
|
||||||
|
</span>
|
||||||
|
<span class="badge-label">IA souveraine</span>
|
||||||
|
<span class="badge-detail">Mistral AI, Paris - conforme RGPD ; pas de collecte ni de profiling ; aucun transfert hors UE</span>
|
||||||
|
</div>
|
||||||
|
<div class="badge">
|
||||||
|
<span class="badge-icon" aria-hidden="true">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>
|
||||||
|
</span>
|
||||||
|
<span class="badge-label">Hébergé en Europe</span>
|
||||||
|
<span class="badge-detail">Hetzner, Allemagne</span>
|
||||||
|
</div>
|
||||||
|
<div class="badge">
|
||||||
|
<span class="badge-icon" aria-hidden="true">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
|
||||||
|
</span>
|
||||||
|
<span class="badge-label">Zéro cookie US</span>
|
||||||
|
<span class="badge-detail">Pas de Google Analytics, pas de Meta Pixel, pas de tracker tiers ; on ne sait rien de toi</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ══════════════════════════════════════════════════════════
|
||||||
|
SECTION 3 - Gouvernance
|
||||||
|
══════════════════════════════════════════════════════════ -->
|
||||||
|
<!-- TODO Jules : Écrire qui porte ce projet, le collectif, la posture politique, le lien Liberapay -->
|
||||||
|
<section class="section">
|
||||||
|
<h2>Gouvernance</h2>
|
||||||
|
<p class="section-text">
|
||||||
|
Architecture d'Écologie Politique est un collectif en train de se monter - ouvert à toustes, à gouvernance partagée. Les décisions se prennent en transparence. Si tu veux participer, contribuer, ou simplement suivre ce qui se construit : tu es la bienvenu·e. Contact : <a href="mailto:contact@trans-former.fr" class="text-link">contact@trans-former.fr</a>
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ══════════════════════════════════════════════════════════
|
||||||
|
SECTION 4 - Transparence Liberapay
|
||||||
|
══════════════════════════════════════════════════════════ -->
|
||||||
|
<!-- TODO Jules : Écrire pourquoi Liberapay (pas Patreon, pas Ko-fi), le modèle de financement voulu -->
|
||||||
|
<section class="section section-liberapay">
|
||||||
|
<h2>Soutenir le projet</h2>
|
||||||
|
<p class="section-text">
|
||||||
|
Ce projet est participatif, libre, open source. Il existe parce que la communauté l'utilise - et il ne peut continuer à exister que si la communauté le soutient.
|
||||||
|
</p>
|
||||||
|
<p class="section-text">
|
||||||
|
Le financer, c'est soutenir la structuration d'un écosystème d'entraide architecturale qui, aujourd'hui, est assez défaillant. Si toi aussi tu as manqué de ressources en chemin, si tu as dû réinventer seul·e ce que d'autres avaient déjà compris, si tu as cherché un filet et ne l'as pas trouvé - tu sais exactement pourquoi ça compte. Ce que tu mets dans la cagnotte ne va pas dans une boîte ; ça va dans un outil qui permet que la prochaine génération d'architectes ait ce que nous n'avons pas eu : un réseau d'entraide qui fonctionne, au service de pratiques plus épanouissantes, plus politiques, plus écologiques.
|
||||||
|
</p>
|
||||||
|
<p class="liberapay-note">
|
||||||
|
Liberapay est une plateforme open source EU (ASBL belge non-lucrative), 0% de frais.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Bouton CTA -->
|
||||||
|
<a
|
||||||
|
href="https://liberapay.com/trans-former.fr/donate"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="btn-liberapay"
|
||||||
|
>
|
||||||
|
Soutenir sur Liberapay →
|
||||||
|
</a>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ══════════════════════════════════════════════════════════
|
||||||
|
SECTION 5 - Transparence IA
|
||||||
|
══════════════════════════════════════════════════════════ -->
|
||||||
|
<!-- TODO Jules : Écrire la posture sur l'IA - pourquoi ces modèles, pourquoi la transparence, ce que ça signifie politiquement -->
|
||||||
|
<section id="ia" class="section">
|
||||||
|
<h2>Transparence IA</h2>
|
||||||
|
<p class="section-text">
|
||||||
|
Ce site a été construit grâce à l'IA, par quelqu'un qui n'est pas développeur. C'est une manière de subvertir les outils des Big Tech pour en faire quelque chose de communautaire et de souverain : une infrastructure locale, des données qu'on contrôle, un commun qui n'appartient à personne en particulier.
|
||||||
|
</p>
|
||||||
|
<p class="section-text">
|
||||||
|
L'IA est un outil. La question, c'est au service de quoi. Oui, elle est énergivore - et il faut l'assumer. Mais si c'est au service de l'émancipation, du collectif, de transformer nos infrastructures sociétales, alors on dit oui - en sachant que c'est une étape transitoire. L'infrastructure de l'IA elle-même peut évoluer : devenir plus locale, plus sobre, plus souveraine. C'est dans cette direction qu'on regarde.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="ia-grid">
|
||||||
|
<div class="ia-card">
|
||||||
|
<div class="ia-card-label">Enrichissement des fiches</div>
|
||||||
|
<div class="ia-card-model">Mistral Nemo</div>
|
||||||
|
<div class="ia-card-cost">~0,00003 €/fiche</div>
|
||||||
|
</div>
|
||||||
|
<div class="ia-card">
|
||||||
|
<div class="ia-card-label">Chatbot de recherche</div>
|
||||||
|
<div class="ia-card-model">Mistral Small</div>
|
||||||
|
<div class="ia-card-cost">~0,003 €/requête</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul class="ia-details">
|
||||||
|
<li>10 € / mois investis au départ par le collectif - vos dons constituent la réserve pour faire vivre le projet et financer ses recherches</li>
|
||||||
|
<li>Coût visible en direct dans le bandeau bas de la carte</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ══════════════════════════════════════════════════════════
|
||||||
|
SECTION 6 - Contribuer
|
||||||
|
══════════════════════════════════════════════════════════ -->
|
||||||
|
<!-- TODO Jules : Écrire l'invitation à contribuer - ton, posture, ce qu'on attend des contributeurs -->
|
||||||
|
<section class="section section-contribuer">
|
||||||
|
<h2>Contribuer</h2>
|
||||||
|
<p class="section-text">
|
||||||
|
Il n'y a pas de barre d'entrée ici. Une correction, une ressource à signaler, un retour sur ta propre expérience - c'est déjà participer à quelque chose qui grandit par accumulation de petits gestes.
|
||||||
|
</p>
|
||||||
|
<div class="contribuer-ctas">
|
||||||
|
<NuxtLink to="/contribuer" class="btn-primary">
|
||||||
|
Proposer une fiche →
|
||||||
|
</NuxtLink>
|
||||||
|
<a
|
||||||
|
href="https://trans-former.fr/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="btn-secondary"
|
||||||
|
>
|
||||||
|
Blog AEP
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="https://www.instagram.com/aep.politique/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="btn-secondary"
|
||||||
|
>
|
||||||
|
Instagram
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="https://liberapay.com/trans-former.fr/donate"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="btn-secondary"
|
||||||
|
>
|
||||||
|
Soutenir
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="mailto:contact@trans-former.fr"
|
||||||
|
class="btn-secondary"
|
||||||
|
>
|
||||||
|
Contact
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ══════════════════════════════════════════════════════════
|
||||||
|
SECTION 7 - Open Source
|
||||||
|
══════════════════════════════════════════════════════════ -->
|
||||||
|
<section class="section section-opensource">
|
||||||
|
<h2>Code source ouvert</h2>
|
||||||
|
<p class="section-text">
|
||||||
|
Cette carte est un commun technique autant qu'un commun de ressources. Le code source est libre — tu peux le forker, l'adapter, le déployer pour ta propre cartographie d'entraide, ton territoire, ta filière.
|
||||||
|
</p>
|
||||||
|
<p class="section-text">
|
||||||
|
Une liste de ressources locales pour les artisans ? Un réseau d'entraide pour les soignants indépendants ? La structure est la même. L'IA d'enrichissement est la même. Il suffit de changer les données.
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
href="https://git.trans-former.fr/jules/nav-carte"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="btn-github"
|
||||||
|
>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0 0 24 12c0-6.63-5.37-12-12-12z"/></svg>
|
||||||
|
Code source — git.trans-former.fr →
|
||||||
|
</a>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
useHead({ title: 'À propos - AEP' })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* ── Layout ─────────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.apropos-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: var(--nav-bg);
|
||||||
|
padding: 1.5rem 1rem 5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.apropos-inner {
|
||||||
|
max-width: 720px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Retour ──────────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.back-link {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--nav-primary-solid);
|
||||||
|
opacity: 0.7;
|
||||||
|
text-decoration: none;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-link:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Sections ────────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.section-mission,
|
||||||
|
.section {
|
||||||
|
margin-bottom: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-mission h1 {
|
||||||
|
font-size: 1.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--nav-text);
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section h2 {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--nav-text);
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
border-bottom: 1px solid var(--nav-bg-alt);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mission-text,
|
||||||
|
.section-intro,
|
||||||
|
.section-text {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: var(--nav-text);
|
||||||
|
line-height: 1.65;
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-intro {
|
||||||
|
color: var(--nav-text-muted);
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-link {
|
||||||
|
color: var(--nav-primary-solid);
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-link:hover {
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Badges souveraineté ──────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.badges-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.625rem 0.875rem;
|
||||||
|
background: var(--nav-bg-alt);
|
||||||
|
border-radius: 9999px;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 160px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 560px) {
|
||||||
|
.badge {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
flex: none;
|
||||||
|
min-width: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-icon {
|
||||||
|
color: var(--nav-text-muted);
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-label {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--nav-text);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-detail {
|
||||||
|
font-size: 0.775rem;
|
||||||
|
color: var(--nav-text-muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 559px) {
|
||||||
|
.badge {
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
}
|
||||||
|
.badge-label {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Liberapay ───────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.liberapay-note {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--nav-text-muted);
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-liberapay {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding: 0.625rem 1.25rem;
|
||||||
|
background: var(--nav-primary);
|
||||||
|
color: var(--nav-text-on-primary);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-liberapay:hover {
|
||||||
|
background: rgba(26, 34, 56, 0.75);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── IA grid ─────────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.ia-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.ia-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ia-card {
|
||||||
|
background: var(--nav-surface);
|
||||||
|
border: 1px solid rgba(26, 34, 56, 0.1);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 1rem 1.125rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ia-card-label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--nav-text-muted);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ia-card-model {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--nav-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ia-card-cost {
|
||||||
|
font-size: 0.825rem;
|
||||||
|
color: var(--nav-text-muted);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ia-details {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ia-details li {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--nav-text-muted);
|
||||||
|
padding-left: 1rem;
|
||||||
|
position: relative;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ia-details li::before {
|
||||||
|
content: '—';
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
color: var(--nav-text-muted);
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── CTAs contribuer ─────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.contribuer-ctas {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
background: var(--nav-accent);
|
||||||
|
color: var(--nav-text);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.75rem 1.25rem;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--nav-text-muted);
|
||||||
|
border: 1px solid rgba(26, 34, 56, 0.2);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: border-color 0.15s, color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
border-color: var(--nav-primary-solid);
|
||||||
|
color: var(--nav-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── GitHub open source ──────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.section-opensource {
|
||||||
|
border-top: 1px solid var(--nav-bg-alt);
|
||||||
|
padding-top: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-github {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.625rem 1.25rem;
|
||||||
|
background: var(--nav-surface);
|
||||||
|
border: 1px solid rgba(26, 34, 56, 0.2);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--nav-text);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: border-color 0.15s, background 0.15s;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-github:hover {
|
||||||
|
border-color: var(--nav-primary-solid);
|
||||||
|
background: var(--nav-bg-alt);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Responsive général ──────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.apropos-page {
|
||||||
|
padding: 1rem 0.75rem 4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-mission h1 {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contribuer-ctas {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary,
|
||||||
|
.btn-secondary {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
39
pages/agences.vue
Normal file
39
pages/agences.vue
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex flex-col items-center justify-center h-full gap-6" style="background: var(--nav-bg);">
|
||||||
|
<div class="text-center max-w-md px-6">
|
||||||
|
<div
|
||||||
|
class="inline-flex items-center justify-center w-14 h-14 rounded-2xl mb-5"
|
||||||
|
style="background: var(--nav-bg-alt);"
|
||||||
|
>
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="color: var(--nav-text-muted);">
|
||||||
|
<rect x="3" y="3" width="7" height="7"/>
|
||||||
|
<rect x="14" y="3" width="7" height="7"/>
|
||||||
|
<rect x="14" y="14" width="7" height="7"/>
|
||||||
|
<rect x="3" y="14" width="7" height="7"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h1 class="text-2xl font-bold mb-3" style="color: var(--nav-text);">Agences Inspirantes</h1>
|
||||||
|
<p class="text-sm leading-relaxed mb-6" style="color: var(--nav-text-muted);">
|
||||||
|
Cette section répertoriera les agences d'architecture qui incarnent une pratique engagée — écologie politique, auto-construction, architectures vernaculaires, sobriété.
|
||||||
|
</p>
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-widest mb-6" style="color: var(--nav-text-muted); opacity: 0.6;">
|
||||||
|
Bientôt disponible
|
||||||
|
</p>
|
||||||
|
<NuxtLink
|
||||||
|
to="/"
|
||||||
|
class="inline-flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-semibold transition-all hover:opacity-80"
|
||||||
|
style="background: var(--nav-primary); color: var(--nav-text-on-primary);"
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" aria-hidden="true">
|
||||||
|
<line x1="19" y1="12" x2="5" y2="12"/>
|
||||||
|
<polyline points="12 19 5 12 12 5"/>
|
||||||
|
</svg>
|
||||||
|
Retour à l'écosystème
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
useHead({ title: 'Agences Inspirantes — AEP (bientôt disponible)' })
|
||||||
|
</script>
|
||||||
215
pages/ajouter.vue
Normal file
215
pages/ajouter.vue
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
<template>
|
||||||
|
<div class="max-w-2xl mx-auto px-4 py-8">
|
||||||
|
<NuxtLink to="/" class="text-sm text-sage-600 hover:text-sage-800 inline-flex items-center gap-1 mb-6">
|
||||||
|
← Retour à la liste
|
||||||
|
</NuxtLink>
|
||||||
|
|
||||||
|
<h1 class="text-2xl font-bold text-gray-900 mb-2">Proposer une fiche</h1>
|
||||||
|
<p class="text-gray-500 text-sm mb-8">
|
||||||
|
Tu connais une institution, association ou collectif qui mérite d'être référencé ? Propose-le ici. La fiche sera visible après validation par l'équipe.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div v-if="success" class="bg-sage-100 border border-sage-300 text-sage-800 rounded-xl px-6 py-5 text-center">
|
||||||
|
<p class="font-semibold text-lg mb-1">Merci !</p>
|
||||||
|
<p class="text-sm">Ta fiche sera visible après modération. On l'examine généralement sous 48h.</p>
|
||||||
|
<button @click="reset" class="mt-4 text-sage-600 underline text-sm hover:text-sage-800">
|
||||||
|
Proposer une autre fiche
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form v-else @submit.prevent="submit" class="space-y-5">
|
||||||
|
<!-- Nom -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Nom de l'organisation *</label>
|
||||||
|
<input
|
||||||
|
v-model="form.nom"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
placeholder="Ex : UNSFA, Maison de l'Architecture..."
|
||||||
|
class="w-full px-3 py-2.5 rounded-lg border border-warm-300 focus:outline-none focus:ring-2 focus:ring-sage-400 bg-white text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Type -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Type d'organisation *</label>
|
||||||
|
<select
|
||||||
|
v-model="form.type_org"
|
||||||
|
required
|
||||||
|
class="w-full px-3 py-2.5 rounded-lg border border-warm-300 focus:outline-none focus:ring-2 focus:ring-sage-400 bg-white text-sm text-gray-700"
|
||||||
|
>
|
||||||
|
<option value="" disabled>Choisir un type...</option>
|
||||||
|
<option value="association">Association</option>
|
||||||
|
<option value="syndicat">Syndicat</option>
|
||||||
|
<option value="institution">Institution</option>
|
||||||
|
<option value="reseau">Réseau</option>
|
||||||
|
<option value="collectif">Collectif</option>
|
||||||
|
<option value="ecole">École</option>
|
||||||
|
<option value="media">Média</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Description -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Description *
|
||||||
|
<span class="font-normal text-gray-400">(environ 200 caractères)</span>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
v-model="form.description"
|
||||||
|
required
|
||||||
|
rows="4"
|
||||||
|
placeholder="Présente l'organisation en quelques mots : ses missions, son public cible, ce qu'elle apporte à la communauté..."
|
||||||
|
class="w-full px-3 py-2.5 rounded-lg border border-warm-300 focus:outline-none focus:ring-2 focus:ring-sage-400 bg-white text-sm resize-none"
|
||||||
|
/>
|
||||||
|
<p class="text-xs text-gray-400 mt-1">{{ form.description.length }} caractères</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Lien -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Site web <span class="font-normal text-gray-400">(facultatif)</span></label>
|
||||||
|
<input
|
||||||
|
v-model="form.lien"
|
||||||
|
type="url"
|
||||||
|
placeholder="https://..."
|
||||||
|
class="w-full px-3 py-2.5 rounded-lg border border-warm-300 focus:outline-none focus:ring-2 focus:ring-sage-400 bg-white text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tags -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Tags <span class="font-normal text-gray-400">(séparés par des virgules)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model="form.tags"
|
||||||
|
type="text"
|
||||||
|
placeholder="Ex : formation, concours, droit, Paris..."
|
||||||
|
class="w-full px-3 py-2.5 rounded-lg border border-warm-300 focus:outline-none focus:ring-2 focus:ring-sage-400 bg-white text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Séparateur -->
|
||||||
|
<div class="border-t border-warm-200 pt-5">
|
||||||
|
<p class="text-sm font-medium text-gray-700 mb-4">Ton expérience avec cette organisation</p>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Ton prénom *</label>
|
||||||
|
<input
|
||||||
|
v-model="form.auteur"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
placeholder="Marie"
|
||||||
|
class="w-full px-3 py-2.5 rounded-lg border border-warm-300 focus:outline-none focus:ring-2 focus:ring-sage-400 bg-white text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Ton avis / expérience *
|
||||||
|
<span class="font-normal text-gray-400">(environ 500 caractères)</span>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
v-model="form.avis"
|
||||||
|
required
|
||||||
|
rows="5"
|
||||||
|
placeholder="Pourquoi tu recommandes cet endroit ? Qu'est-ce que ça t'a apporté ?"
|
||||||
|
class="w-full px-3 py-2.5 rounded-lg border border-warm-300 focus:outline-none focus:ring-2 focus:ring-sage-400 bg-white text-sm resize-none"
|
||||||
|
/>
|
||||||
|
<p class="text-xs text-gray-400 mt-1">{{ form.avis.length }} caractères</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Erreur -->
|
||||||
|
<div v-if="error" class="text-red-600 text-sm bg-red-50 border border-red-200 rounded-lg px-4 py-3">
|
||||||
|
Une erreur s'est produite. Réessaie dans quelques instants.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
:disabled="submitting"
|
||||||
|
class="w-full bg-sage-600 hover:bg-sage-700 disabled:opacity-50 text-white font-semibold py-3 rounded-xl transition-colors text-sm"
|
||||||
|
>
|
||||||
|
{{ submitting ? 'Envoi en cours...' : 'Proposer cette fiche' }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<p class="text-xs text-gray-400 text-center">
|
||||||
|
Ta fiche sera examinée par l'équipe avant publication.
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const form = reactive({
|
||||||
|
nom: '',
|
||||||
|
type_org: '',
|
||||||
|
description: '',
|
||||||
|
lien: '',
|
||||||
|
tags: '',
|
||||||
|
auteur: '',
|
||||||
|
avis: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const submitting = ref(false)
|
||||||
|
const success = ref(false)
|
||||||
|
const error = ref(false)
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
submitting.value = true
|
||||||
|
error.value = false
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. POST organisation avec status=pending
|
||||||
|
const orgResult: any = await $fetch('/api/organisations', {
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
nom: form.nom,
|
||||||
|
type_org: form.type_org,
|
||||||
|
description: form.description,
|
||||||
|
lien: form.lien || null,
|
||||||
|
tags: form.tags || null,
|
||||||
|
status: 'pending',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// 2. POST avis lié à l'organisation
|
||||||
|
const newOrgId = orgResult?.Id ?? orgResult?.id
|
||||||
|
if (newOrgId && form.avis.trim()) {
|
||||||
|
await $fetch('/api/avis', {
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
organisation_id: newOrgId,
|
||||||
|
auteur: form.auteur,
|
||||||
|
texte: form.avis,
|
||||||
|
status: 'pending',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
success.value = true
|
||||||
|
} catch (e) {
|
||||||
|
error.value = true
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
Object.assign(form, {
|
||||||
|
nom: '',
|
||||||
|
type_org: '',
|
||||||
|
description: '',
|
||||||
|
lien: '',
|
||||||
|
tags: '',
|
||||||
|
auteur: '',
|
||||||
|
avis: '',
|
||||||
|
})
|
||||||
|
success.value = false
|
||||||
|
error.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
useHead({ title: 'Proposer une fiche — AEP' })
|
||||||
|
</script>
|
||||||
795
pages/contribuer.vue
Normal file
795
pages/contribuer.vue
Normal file
@@ -0,0 +1,795 @@
|
|||||||
|
<template>
|
||||||
|
<div class="contribuer-page">
|
||||||
|
<div class="contribuer-inner">
|
||||||
|
<!-- Retour -->
|
||||||
|
<NuxtLink to="/" class="back-link">
|
||||||
|
← Retour à la carte
|
||||||
|
</NuxtLink>
|
||||||
|
|
||||||
|
<!-- En-tête -->
|
||||||
|
<div class="contribuer-header">
|
||||||
|
<h1>Proposer une ressource</h1>
|
||||||
|
<p class="contribuer-subtitle">
|
||||||
|
Tu connais une organisation utile aux architectes qui n'est pas encore référencée ?
|
||||||
|
Soumets-la ici — une IA enrichira la fiche et on validera sous 7 jours.
|
||||||
|
</p>
|
||||||
|
<p class="contribuer-hint">
|
||||||
|
Si tu n'as pas le temps de tout remplir, laisse-nous juste le lien — on extraira les infos du site.
|
||||||
|
Mais une description de toi, c'est toujours plus vivant et plus précis.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Message succès -->
|
||||||
|
<div v-if="success" class="success-block" role="status" aria-live="polite">
|
||||||
|
<div class="success-icon">✓</div>
|
||||||
|
<h2>Merci !</h2>
|
||||||
|
<p>Ta fiche est en cours de traitement.</p>
|
||||||
|
<p class="success-detail">
|
||||||
|
Une IA va scraper le site et enrichir la description.
|
||||||
|
Jules (et bientôt une équipe de modération) valide sous 7 jours.
|
||||||
|
</p>
|
||||||
|
<button type="button" class="btn-secondary" @click="reset">
|
||||||
|
Proposer une autre fiche
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Formulaire -->
|
||||||
|
<form v-else @submit.prevent="submit" class="contribuer-form" novalidate>
|
||||||
|
|
||||||
|
<!-- Nom -->
|
||||||
|
<div class="field-group" :class="{ 'field-error': errors.nom }">
|
||||||
|
<label for="nom">Nom de l'organisation <span class="required">*</span></label>
|
||||||
|
<input
|
||||||
|
id="nom"
|
||||||
|
v-model="form.nom"
|
||||||
|
type="text"
|
||||||
|
placeholder="Ex : UNSFA, Maison de l'Architecture..."
|
||||||
|
autocomplete="organization"
|
||||||
|
@blur="validateField('nom')"
|
||||||
|
/>
|
||||||
|
<span v-if="errors.nom" class="error-msg" role="alert">{{ errors.nom }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- URL -->
|
||||||
|
<div class="field-group" :class="{ 'field-error': errors.url }">
|
||||||
|
<label for="url">
|
||||||
|
Site web
|
||||||
|
<span class="label-hint">(optionnel — recommandé pour l'enrichissement IA)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="url"
|
||||||
|
v-model="form.url"
|
||||||
|
type="url"
|
||||||
|
placeholder="https://..."
|
||||||
|
@blur="validateField('url')"
|
||||||
|
/>
|
||||||
|
<span v-if="errors.url" class="error-msg" role="alert">{{ errors.url }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Description -->
|
||||||
|
<div class="field-group" :class="{ 'field-error': errors.description_user }">
|
||||||
|
<label for="description_user">
|
||||||
|
Description courte <span class="required">*</span>
|
||||||
|
<span class="label-hint">(50 à 500 caractères)</span>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="description_user"
|
||||||
|
v-model="form.description_user"
|
||||||
|
rows="4"
|
||||||
|
placeholder="Présente l'organisation en quelques mots : ses missions, son public, ce qu'elle apporte..."
|
||||||
|
@blur="validateField('description_user')"
|
||||||
|
/>
|
||||||
|
<div class="field-meta">
|
||||||
|
<span v-if="errors.description_user" class="error-msg" role="alert">
|
||||||
|
{{ errors.description_user }}
|
||||||
|
</span>
|
||||||
|
<span v-else class="char-count" :class="{ 'char-warn': form.description_user.length > 450 }">
|
||||||
|
{{ form.description_user.length }}/500
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Échelle -->
|
||||||
|
<div class="field-group" :class="{ 'field-error': errors.echelle }">
|
||||||
|
<fieldset>
|
||||||
|
<legend>
|
||||||
|
Échelle <span class="required">*</span>
|
||||||
|
<span class="label-hint">(une seule)</span>
|
||||||
|
</legend>
|
||||||
|
<div class="radio-group">
|
||||||
|
<label
|
||||||
|
v-for="opt in ECHELLES"
|
||||||
|
:key="opt"
|
||||||
|
class="radio-label"
|
||||||
|
:class="{ active: form.echelle === opt }"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
:value="opt"
|
||||||
|
v-model="form.echelle"
|
||||||
|
name="echelle"
|
||||||
|
@change="validateField('echelle')"
|
||||||
|
/>
|
||||||
|
{{ opt }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
<span v-if="errors.echelle" class="error-msg" role="alert">{{ errors.echelle }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Fonctions -->
|
||||||
|
<div class="field-group" :class="{ 'field-error': errors.fonctions }">
|
||||||
|
<fieldset>
|
||||||
|
<legend>
|
||||||
|
Fonctions <span class="required">*</span>
|
||||||
|
<span class="label-hint">(1 à 5 — l'ordre de clic = priorité)</span>
|
||||||
|
</legend>
|
||||||
|
<div class="checkbox-grid">
|
||||||
|
<label
|
||||||
|
v-for="fn in FONCTIONS"
|
||||||
|
:key="fn"
|
||||||
|
class="checkbox-label"
|
||||||
|
:class="{
|
||||||
|
active: form.fonctions.includes(fn),
|
||||||
|
disabled: !form.fonctions.includes(fn) && form.fonctions.length >= 5,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
:value="fn"
|
||||||
|
:checked="form.fonctions.includes(fn)"
|
||||||
|
:disabled="!form.fonctions.includes(fn) && form.fonctions.length >= 5"
|
||||||
|
@change="toggleFonction(fn)"
|
||||||
|
/>
|
||||||
|
<span class="fn-order" v-if="form.fonctions.includes(fn)">
|
||||||
|
{{ form.fonctions.indexOf(fn) + 1 }}
|
||||||
|
</span>
|
||||||
|
{{ fn }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
<span v-if="errors.fonctions" class="error-msg" role="alert">{{ errors.fonctions }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Territoire -->
|
||||||
|
<div class="field-group" :class="{ 'field-error': errors.territoire }">
|
||||||
|
<fieldset>
|
||||||
|
<legend>
|
||||||
|
Territoire <span class="required">*</span>
|
||||||
|
</legend>
|
||||||
|
<div class="radio-group">
|
||||||
|
<label
|
||||||
|
v-for="t in TERRITOIRES"
|
||||||
|
:key="t"
|
||||||
|
class="radio-label"
|
||||||
|
:class="{ active: form.territoire === t }"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
:value="t"
|
||||||
|
v-model="form.territoire"
|
||||||
|
name="territoire"
|
||||||
|
@change="validateField('territoire')"
|
||||||
|
/>
|
||||||
|
{{ t }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
<span v-if="errors.territoire" class="error-msg" role="alert">{{ errors.territoire }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Ville -->
|
||||||
|
<div class="field-group" :class="{ 'field-error': errors.localisation_ville }">
|
||||||
|
<label for="localisation_ville">
|
||||||
|
Ville principale
|
||||||
|
<span class="label-hint">(optionnel — pour la géolocalisation sur la carte)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="localisation_ville"
|
||||||
|
v-model="form.localisation_ville"
|
||||||
|
type="text"
|
||||||
|
placeholder="Ex : Paris, Lyon, Bordeaux..."
|
||||||
|
/>
|
||||||
|
<span v-if="errors.localisation_ville" class="error-msg" role="alert">
|
||||||
|
{{ errors.localisation_ville }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Email -->
|
||||||
|
<div class="field-group" :class="{ 'field-error': errors.submitted_by_email }">
|
||||||
|
<label for="submitted_by_email">
|
||||||
|
Ton email
|
||||||
|
<span class="label-hint">(optionnel — pour le suivi de modération)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="submitted_by_email"
|
||||||
|
v-model="form.submitted_by_email"
|
||||||
|
type="email"
|
||||||
|
placeholder="ton@email.fr"
|
||||||
|
autocomplete="email"
|
||||||
|
@blur="validateField('submitted_by_email')"
|
||||||
|
/>
|
||||||
|
<span v-if="errors.submitted_by_email" class="error-msg" role="alert">
|
||||||
|
{{ errors.submitted_by_email }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Erreur globale -->
|
||||||
|
<div v-if="serverError" class="server-error" role="alert">
|
||||||
|
<strong>Erreur :</strong> {{ serverError }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="form-actions">
|
||||||
|
<NuxtLink to="/" class="btn-secondary">Annuler</NuxtLink>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="btn-primary"
|
||||||
|
:disabled="submitting"
|
||||||
|
>
|
||||||
|
{{ submitting ? 'Envoi en cours...' : 'Proposer la fiche →' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="form-note">
|
||||||
|
Ta fiche sera examinée par l'équipe avant publication.
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
// ── Constantes ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const ECHELLES = ['National', 'Régional', 'Local'] as const
|
||||||
|
const TERRITOIRES = ['Métropole', 'Guadeloupe', 'Martinique', 'Guyane', 'La Réunion', 'Mayotte'] as const
|
||||||
|
const FONCTIONS = [
|
||||||
|
'Juridique', 'Technique', 'Économique', 'Administratif', 'Chantier',
|
||||||
|
'Comptabilité', 'Développement', 'Formation', 'Gestion d\'agence', 'Santé mentale',
|
||||||
|
] as const
|
||||||
|
|
||||||
|
// ── Schéma Zod (côté client — miroir du serveur) ──────────────────────────────
|
||||||
|
|
||||||
|
const SubmitSchema = z.object({
|
||||||
|
nom: z.string().min(3, 'Minimum 3 caractères').max(150, 'Maximum 150 caractères').trim(),
|
||||||
|
url: z.string().url('URL invalide (commencer par https://)').optional().or(z.literal('')),
|
||||||
|
description_user: z.string().min(50, 'Minimum 50 caractères').max(500, 'Maximum 500 caractères').trim(),
|
||||||
|
echelle: z.enum(ECHELLES, { errorMap: () => ({ message: 'Sélectionne une échelle' }) }),
|
||||||
|
fonctions: z.array(z.string()).min(1, 'Sélectionne au moins une fonction').max(5, 'Maximum 5 fonctions'),
|
||||||
|
territoire: z.enum(TERRITOIRES, { errorMap: () => ({ message: 'Sélectionne un territoire' }) }),
|
||||||
|
localisation_ville: z.string().max(100).optional(),
|
||||||
|
submitted_by_email: z.string().email('Email invalide').optional().or(z.literal('')),
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── État du formulaire ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
nom: '',
|
||||||
|
url: '',
|
||||||
|
description_user: '',
|
||||||
|
echelle: '' as typeof ECHELLES[number] | '',
|
||||||
|
fonctions: [] as string[],
|
||||||
|
territoire: '' as typeof TERRITOIRES[number] | '',
|
||||||
|
localisation_ville: '',
|
||||||
|
submitted_by_email: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const errors = reactive<Record<string, string>>({})
|
||||||
|
const submitting = ref(false)
|
||||||
|
const success = ref(false)
|
||||||
|
const serverError = ref('')
|
||||||
|
const trackingUrl = ref<string | null>(null)
|
||||||
|
|
||||||
|
// ── Validation champ par champ ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function validateField(field: string) {
|
||||||
|
const partial = SubmitSchema.partial()
|
||||||
|
const result = partial.safeParse({ [field]: (form as any)[field] })
|
||||||
|
if (!result.success) {
|
||||||
|
const fieldErrors = result.error.flatten().fieldErrors
|
||||||
|
errors[field] = fieldErrors[field]?.[0] ?? ''
|
||||||
|
} else {
|
||||||
|
delete errors[field]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateAll(): boolean {
|
||||||
|
const result = SubmitSchema.safeParse(form)
|
||||||
|
if (!result.success) {
|
||||||
|
const flat = result.error.flatten().fieldErrors
|
||||||
|
Object.assign(errors, Object.fromEntries(
|
||||||
|
Object.entries(flat).map(([k, v]) => [k, v?.[0] ?? ''])
|
||||||
|
))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
Object.keys(errors).forEach(k => delete errors[k])
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Gestion fonctions (ordre de clic = priorité) ──────────────────────────────
|
||||||
|
|
||||||
|
function toggleFonction(fn: string) {
|
||||||
|
const idx = form.fonctions.indexOf(fn)
|
||||||
|
if (idx >= 0) {
|
||||||
|
form.fonctions.splice(idx, 1)
|
||||||
|
} else if (form.fonctions.length < 5) {
|
||||||
|
form.fonctions.push(fn)
|
||||||
|
}
|
||||||
|
validateField('fonctions')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Soumission ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
serverError.value = ''
|
||||||
|
|
||||||
|
if (!validateAll()) {
|
||||||
|
// Scroll vers la première erreur
|
||||||
|
await nextTick()
|
||||||
|
const firstError = document.querySelector('.field-error')
|
||||||
|
firstError?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
submitting.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result: any = await $fetch('/api/submit', {
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
nom: form.nom,
|
||||||
|
url: form.url || undefined,
|
||||||
|
description_user: form.description_user,
|
||||||
|
echelle: form.echelle,
|
||||||
|
fonctions: form.fonctions,
|
||||||
|
territoire: form.territoire,
|
||||||
|
localisation_ville: form.localisation_ville || undefined,
|
||||||
|
submitted_by_email: form.submitted_by_email || undefined,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
trackingUrl.value = result.trackingUrl ?? null
|
||||||
|
success.value = true
|
||||||
|
} catch (e: any) {
|
||||||
|
const status = e?.status ?? e?.statusCode
|
||||||
|
if (status === 429) {
|
||||||
|
serverError.value = 'Tu as déjà soumis 3 fiches aujourd\'hui. Réessaie demain.'
|
||||||
|
} else if (status === 422 && e?.data) {
|
||||||
|
// Erreurs Zod serveur → mapper sur le formulaire
|
||||||
|
const fieldErrors = e.data
|
||||||
|
Object.entries(fieldErrors).forEach(([k, v]) => {
|
||||||
|
errors[k] = Array.isArray(v) ? v[0] : String(v)
|
||||||
|
})
|
||||||
|
serverError.value = 'Certains champs sont invalides — vérifie les erreurs ci-dessus.'
|
||||||
|
} else {
|
||||||
|
serverError.value = 'Une erreur s\'est produite. Réessaie dans quelques instants.'
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
Object.assign(form, {
|
||||||
|
nom: '', url: '', description_user: '', echelle: '',
|
||||||
|
fonctions: [], territoire: '', localisation_ville: '', submitted_by_email: '',
|
||||||
|
})
|
||||||
|
Object.keys(errors).forEach(k => delete errors[k])
|
||||||
|
success.value = false
|
||||||
|
serverError.value = ''
|
||||||
|
trackingUrl.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Meta ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
useHead({ title: 'Proposer une ressource — AEP' })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* ── Layout ─────────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.contribuer-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: var(--nav-bg);
|
||||||
|
padding: 1.5rem 1rem 4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contribuer-inner {
|
||||||
|
max-width: 640px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Retour ──────────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.back-link {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--nav-primary-solid);
|
||||||
|
opacity: 0.7;
|
||||||
|
text-decoration: none;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-link:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── En-tête ─────────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.contribuer-header {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contribuer-header h1 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--nav-text);
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contribuer-subtitle {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--nav-text-muted);
|
||||||
|
line-height: 1.5;
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contribuer-hint {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--nav-text-muted);
|
||||||
|
opacity: 0.75;
|
||||||
|
line-height: 1.5;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Succès ──────────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.success-block {
|
||||||
|
background: var(--nav-surface);
|
||||||
|
border: 1px solid rgba(26, 34, 56, 0.15);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 2rem 1.5rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-icon {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
background: rgba(26, 34, 56, 0.1);
|
||||||
|
color: var(--nav-text);
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0 auto 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-block h2 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--nav-text);
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-block p {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--nav-text-muted);
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-detail {
|
||||||
|
font-size: 0.85rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-tracking {
|
||||||
|
font-size: 0.85rem !important;
|
||||||
|
margin-top: 1rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tracking-link {
|
||||||
|
color: var(--nav-primary-solid);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Formulaire ──────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.contribuer-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Champ générique ─────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.field-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-group label,
|
||||||
|
.field-group legend {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--nav-text);
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-group fieldset {
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.required {
|
||||||
|
color: #c0392b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label-hint {
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--nav-text-muted);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
margin-left: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-group input[type="text"],
|
||||||
|
.field-group input[type="url"],
|
||||||
|
.field-group input[type="email"],
|
||||||
|
.field-group textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.625rem 0.875rem;
|
||||||
|
border: 1px solid rgba(26, 34, 56, 0.2);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--nav-text);
|
||||||
|
background: var(--nav-surface);
|
||||||
|
font-family: inherit;
|
||||||
|
transition: border-color 0.15s, box-shadow 0.15s;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-group input:focus,
|
||||||
|
.field-group textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--nav-primary-solid);
|
||||||
|
box-shadow: 0 0 0 2px rgba(245, 179, 66, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-group textarea {
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Erreur champ */
|
||||||
|
|
||||||
|
.field-error input,
|
||||||
|
.field-error textarea {
|
||||||
|
border-color: #c0392b !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-msg {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #c0392b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-meta {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.char-count {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--nav-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.char-warn {
|
||||||
|
color: #e67e22;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Radio (Échelle + Territoire) ────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.radio-group {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radio-label {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
padding: 0.375rem 0.75rem;
|
||||||
|
border: 1px solid rgba(26, 34, 56, 0.2);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--nav-text);
|
||||||
|
background: var(--nav-surface);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radio-label input[type="radio"] {
|
||||||
|
position: absolute;
|
||||||
|
opacity: 0;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radio-label:hover {
|
||||||
|
border-color: var(--nav-primary-solid);
|
||||||
|
background: var(--nav-bg-alt);
|
||||||
|
}
|
||||||
|
|
||||||
|
.radio-label.active {
|
||||||
|
background: var(--nav-primary);
|
||||||
|
border-color: transparent;
|
||||||
|
color: var(--nav-text-on-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Checkboxes (Fonctions) ──────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.checkbox-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 400px) {
|
||||||
|
.checkbox-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-label {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
padding: 0.375rem 0.75rem;
|
||||||
|
border: 1px solid rgba(26, 34, 56, 0.2);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--nav-text);
|
||||||
|
background: var(--nav-surface);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
user-select: none;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-label input[type="checkbox"] {
|
||||||
|
position: absolute;
|
||||||
|
opacity: 0;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-label:hover:not(.disabled) {
|
||||||
|
border-color: var(--nav-primary-solid);
|
||||||
|
background: var(--nav-bg-alt);
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-label.active {
|
||||||
|
background: var(--nav-primary);
|
||||||
|
border-color: transparent;
|
||||||
|
color: var(--nav-text-on-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-label.disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fn-order {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
background: var(--nav-accent);
|
||||||
|
color: var(--nav-text);
|
||||||
|
border-radius: 50%;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 700;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Erreur serveur ──────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.server-error {
|
||||||
|
padding: 0.875rem 1rem;
|
||||||
|
background: #fdf0ee;
|
||||||
|
border: 1px solid #e74c3c;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #c0392b;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Actions ──────────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
background: var(--nav-primary);
|
||||||
|
color: var(--nav-text-on-primary);
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
transition: background 0.15s, opacity 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover:not(:disabled) {
|
||||||
|
background: rgba(26, 34, 56, 0.75);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
padding: 0.75rem 1.25rem;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--nav-text-muted);
|
||||||
|
border: 1px solid rgba(26, 34, 56, 0.2);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
transition: border-color 0.15s, color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
border-color: var(--nav-primary-solid);
|
||||||
|
color: var(--nav-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-note {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--nav-text-muted);
|
||||||
|
text-align: center;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Responsive ──────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.contribuer-page {
|
||||||
|
padding: 1rem 0.75rem 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
flex-direction: column-reverse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary,
|
||||||
|
.btn-secondary {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
123
pages/fiche/[id].vue
Normal file
123
pages/fiche/[id].vue
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
<template>
|
||||||
|
<div class="min-h-screen" style="background: var(--nav-bg);">
|
||||||
|
<div class="max-w-4xl mx-auto px-4 py-6">
|
||||||
|
|
||||||
|
<!-- ── Bouton retour carte (préserve filtres URL) ─── -->
|
||||||
|
<NuxtLink
|
||||||
|
:to="retourUrl"
|
||||||
|
class="inline-flex items-center gap-1.5 text-sm mb-6 rounded-lg px-3 py-1.5 transition-colors"
|
||||||
|
style="color: var(--nav-text); background: var(--nav-bg-alt);"
|
||||||
|
aria-label="Retour à la carte"
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<line x1="19" y1="12" x2="5" y2="12"/>
|
||||||
|
<polyline points="12 19 5 12 12 5"/>
|
||||||
|
</svg>
|
||||||
|
Retour à la carte
|
||||||
|
</NuxtLink>
|
||||||
|
|
||||||
|
<!-- ── Chargement ──────────────────────────────────── -->
|
||||||
|
<div v-if="pending" class="py-16 text-center text-sm" style="color: var(--nav-text-muted);">
|
||||||
|
Chargement de la fiche…
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Erreur ──────────────────────────────────────── -->
|
||||||
|
<div v-else-if="error" class="py-16 text-center">
|
||||||
|
<p class="text-lg font-semibold mb-2" style="color: var(--nav-text);">Fiche introuvable</p>
|
||||||
|
<p class="text-sm" style="color: var(--nav-text-muted);">L'organisation demandée n'existe pas ou a été supprimée.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Contenu ─────────────────────────────────────── -->
|
||||||
|
<template v-else-if="org">
|
||||||
|
|
||||||
|
<!-- FicheDetail -->
|
||||||
|
<FicheDetail :org="org" />
|
||||||
|
|
||||||
|
<!-- Séparateur -->
|
||||||
|
<div class="mb-6" style="height: 1px; background: var(--nav-bg-alt);"></div>
|
||||||
|
|
||||||
|
<!-- CommentSection -->
|
||||||
|
<CommentSection :org-id="org.Id" :refresh="commentRefreshTick" />
|
||||||
|
|
||||||
|
<!-- CommentForm -->
|
||||||
|
<CommentForm :org-id="org.Id" @submitted="onCommentSubmitted" />
|
||||||
|
|
||||||
|
</template>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { Org } from '~/types/org'
|
||||||
|
|
||||||
|
// ── Params & route ────────────────────────────────────────────────────
|
||||||
|
const route = useRoute()
|
||||||
|
const orgId = route.params.id as string
|
||||||
|
|
||||||
|
// ── Retour carte — préserve les filtres via sessionStorage ────────────
|
||||||
|
const retourUrl = ref('/')
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
const stored = sessionStorage.getItem('nav_back_filters')
|
||||||
|
if (stored) {
|
||||||
|
retourUrl.value = `/?${stored}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Fetch fiche SSR ───────────────────────────────────────────────────
|
||||||
|
const { data: org, pending, error } = await useFetch<Org>(`/api/fiche/${orgId}`, {
|
||||||
|
key: `fiche-${orgId}`,
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Commentaires — tick de rafraîchissement ───────────────────────────
|
||||||
|
const commentRefreshTick = ref(0)
|
||||||
|
|
||||||
|
function onCommentSubmitted() {
|
||||||
|
// Incrémenter pour que CommentSection se recharge
|
||||||
|
commentRefreshTick.value++
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SEO dynamiques ────────────────────────────────────────────────────
|
||||||
|
const description = computed(() => {
|
||||||
|
if (!org.value) return 'Fiche organisation — AEP'
|
||||||
|
const desc =
|
||||||
|
org.value.description_enrichie ||
|
||||||
|
org.value.description_user ||
|
||||||
|
org.value.description ||
|
||||||
|
''
|
||||||
|
return desc.substring(0, 160).trim()
|
||||||
|
})
|
||||||
|
|
||||||
|
useHead({
|
||||||
|
title: computed(() =>
|
||||||
|
org.value ? `${org.value.nom} — AEP` : 'Fiche organisation — AEP'
|
||||||
|
),
|
||||||
|
meta: [
|
||||||
|
{
|
||||||
|
name: 'description',
|
||||||
|
content: description,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
property: 'og:title',
|
||||||
|
content: computed(() =>
|
||||||
|
org.value ? `${org.value.nom} — AEP` : 'AEP'
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
property: 'og:description',
|
||||||
|
content: description,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
property: 'og:image',
|
||||||
|
content: '/og-default.png', // logo par défaut dans public/
|
||||||
|
},
|
||||||
|
{
|
||||||
|
property: 'og:type',
|
||||||
|
content: 'article',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
</script>
|
||||||
568
pages/index.vue
Normal file
568
pages/index.vue
Normal file
@@ -0,0 +1,568 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex h-full overflow-hidden" style="background: var(--nav-bg);">
|
||||||
|
|
||||||
|
<!-- ═══════════════════════════════════════ SIDEBAR DESKTOP (≥ 1024px) -->
|
||||||
|
<div class="hidden lg:flex w-80 shrink-0 flex-col overflow-hidden">
|
||||||
|
<NavSidebar
|
||||||
|
:search="search"
|
||||||
|
:modeValue="territoireMode"
|
||||||
|
:echelle="echelle"
|
||||||
|
:fonctions="fonctions"
|
||||||
|
:territoire="territoire"
|
||||||
|
:echelleCount="echelleCount"
|
||||||
|
:fonctionCount="fonctionCount"
|
||||||
|
:territoireCount="territoireCount"
|
||||||
|
:resultCount="filtered.length"
|
||||||
|
:orgs="filtered"
|
||||||
|
:selectedId="selectedId"
|
||||||
|
:hasActiveFilters="hasActiveFilters"
|
||||||
|
:pending="pending"
|
||||||
|
@update:search="onSearch"
|
||||||
|
@update:mode="onMode"
|
||||||
|
@update:echelle="onEchelle"
|
||||||
|
@update:fonctions="onFonctions"
|
||||||
|
@update:territoire="onTerritoire"
|
||||||
|
@select-org="onSelectOrg"
|
||||||
|
@hover-org="onHoverOrg"
|
||||||
|
@reset-filters="resetFilters"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ═══════════════════════════════════════ ZONE CENTRALE (carte) -->
|
||||||
|
<main class="flex-1 flex flex-col overflow-hidden relative">
|
||||||
|
|
||||||
|
<!-- Indicateur source dev -->
|
||||||
|
<div
|
||||||
|
v-if="dataSource === 'seed'"
|
||||||
|
class="absolute top-2 left-2 z-[500] px-2 py-1 rounded text-xs"
|
||||||
|
style="background: var(--nav-accent); color: var(--nav-text);"
|
||||||
|
>
|
||||||
|
Mode dev — données seed
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── VUE DESKTOP : Métropole pleine largeur + DOM-TOM row en bas ── -->
|
||||||
|
<div class="hidden lg:flex lg:flex-1 lg:flex-col lg:overflow-hidden">
|
||||||
|
<!-- Carte Métropole — pleine largeur -->
|
||||||
|
<div class="flex flex-col flex-1 overflow-hidden">
|
||||||
|
<div class="relative flex-1" style="min-height: 200px;">
|
||||||
|
<ClientOnly>
|
||||||
|
<NavMap
|
||||||
|
ref="navMapRef"
|
||||||
|
:orgs="metropoleOrgs"
|
||||||
|
:selectedId="selectedId"
|
||||||
|
@select-org="onSelectOrg"
|
||||||
|
/>
|
||||||
|
<template #fallback>
|
||||||
|
<div
|
||||||
|
class="w-full h-full flex items-center justify-center"
|
||||||
|
style="background: var(--nav-bg-alt); color: var(--nav-text-muted);"
|
||||||
|
>
|
||||||
|
Chargement de la carte…
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</ClientOnly>
|
||||||
|
</div>
|
||||||
|
<ChatbotPlaceholder @highlightOrgs="onHighlightOrgs" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bandeau DOM-TOM — row horizontale pleine largeur, hauteur fixe -->
|
||||||
|
<div
|
||||||
|
class="shrink-0"
|
||||||
|
style="height: 140px; border-top: 1px solid var(--nav-bg-alt);"
|
||||||
|
>
|
||||||
|
<ClientOnly>
|
||||||
|
<OutremerMap
|
||||||
|
:orgs="outremerOrgs"
|
||||||
|
:selectedId="selectedId"
|
||||||
|
@select-org="onSelectOrg"
|
||||||
|
/>
|
||||||
|
<template #fallback>
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-center h-full text-sm"
|
||||||
|
style="color: var(--nav-text-muted);"
|
||||||
|
>
|
||||||
|
Chargement…
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</ClientOnly>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── VUE MOBILE : Onglets Métro/Outre-mer + carte pleine hauteur + sheet swipable ── -->
|
||||||
|
|
||||||
|
<!-- Onglets Métropolitain / Outre-mer -->
|
||||||
|
<div class="lg:hidden shrink-0 flex" style="background: var(--nav-surface); border-bottom: 1px solid var(--nav-bg-alt);">
|
||||||
|
<button
|
||||||
|
class="flex-1 py-2 text-sm font-medium transition-colors"
|
||||||
|
:style="mobileMapView === 'metropole'
|
||||||
|
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
|
||||||
|
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
|
||||||
|
@click="mobileMapView = 'metropole'"
|
||||||
|
>Métropolitain</button>
|
||||||
|
<button
|
||||||
|
class="flex-1 py-2 text-sm font-medium transition-colors"
|
||||||
|
:style="mobileMapView === 'outremer'
|
||||||
|
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
|
||||||
|
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
|
||||||
|
@click="mobileMapView = 'outremer'"
|
||||||
|
>Outre-mer</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="lg:hidden flex-1 relative overflow-hidden">
|
||||||
|
|
||||||
|
<!-- Carte Métropole -->
|
||||||
|
<div v-show="mobileMapView === 'metropole'" class="absolute inset-0">
|
||||||
|
<ClientOnly>
|
||||||
|
<NavMap
|
||||||
|
ref="navMapMobileRef"
|
||||||
|
:orgs="metropoleOrgs"
|
||||||
|
:selectedId="selectedId"
|
||||||
|
@select-org="onSelectOrgMobile"
|
||||||
|
/>
|
||||||
|
<template #fallback>
|
||||||
|
<div
|
||||||
|
class="w-full h-full flex items-center justify-center"
|
||||||
|
style="background: var(--nav-bg-alt); color: var(--nav-text-muted);"
|
||||||
|
>
|
||||||
|
Chargement de la carte…
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</ClientOnly>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Carte Outre-mer (scroll vertical, pleine largeur) -->
|
||||||
|
<div v-show="mobileMapView === 'outremer'" class="absolute inset-0 overflow-y-auto" style="background: var(--nav-bg);">
|
||||||
|
<ClientOnly>
|
||||||
|
<OutremerMap
|
||||||
|
:orgs="outremerOrgs"
|
||||||
|
:selectedId="selectedId"
|
||||||
|
@select-org="onSelectOrgMobile"
|
||||||
|
/>
|
||||||
|
<template #fallback>
|
||||||
|
<div class="flex items-center justify-center h-48" style="color: var(--nav-text-muted);">
|
||||||
|
Chargement…
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</ClientOnly>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bottom sheet swipable (Métropole et Outre-mer) -->
|
||||||
|
<ClientOnly>
|
||||||
|
<MobileSheet :resultCount="filtered.length" :pending="pending">
|
||||||
|
<!-- Barre recherche -->
|
||||||
|
<div class="px-3 pt-2 pb-2" style="border-bottom: 1px solid var(--nav-bg-alt);">
|
||||||
|
<label class="mobile-search-label" aria-label="Rechercher une organisation">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="color: var(--nav-text-muted); flex-shrink: 0;">
|
||||||
|
<circle cx="11" cy="11" r="8"/>
|
||||||
|
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
|
||||||
|
</svg>
|
||||||
|
<input
|
||||||
|
v-model="mobileSearch"
|
||||||
|
type="search"
|
||||||
|
placeholder="Rechercher…"
|
||||||
|
class="mobile-search-input"
|
||||||
|
autocomplete="off"
|
||||||
|
@input="onSearch(mobileSearch)"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
v-if="mobileSearch"
|
||||||
|
type="button"
|
||||||
|
class="mobile-search-clear"
|
||||||
|
aria-label="Effacer"
|
||||||
|
@click.stop="mobileSearch = ''; onSearch('')"
|
||||||
|
>
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<!-- Filtres ÉCHELLE — chips style FONCTION -->
|
||||||
|
<div class="mt-2">
|
||||||
|
<span class="text-xs font-bold uppercase tracking-wide block mb-1" style="color: var(--nav-text-muted);">ÉCHELLE</span>
|
||||||
|
<div class="flex flex-wrap gap-1">
|
||||||
|
<span
|
||||||
|
v-for="opt in ECHELLES"
|
||||||
|
:key="opt"
|
||||||
|
class="cursor-pointer px-2 py-0.5 rounded-full text-xs transition-all"
|
||||||
|
:style="echelle.includes(opt)
|
||||||
|
? 'background: var(--nav-primary); color: var(--nav-text-on-primary); font-weight: 600;'
|
||||||
|
: 'background: var(--nav-bg-alt); color: var(--nav-text-muted);'"
|
||||||
|
@click="toggleEchelle(opt)"
|
||||||
|
>{{ opt }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filtres FONCTION — chips flex-wrap -->
|
||||||
|
<div class="mt-2">
|
||||||
|
<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">
|
||||||
|
<span
|
||||||
|
v-for="fn in FONCTIONS"
|
||||||
|
:key="fn"
|
||||||
|
class="cursor-pointer px-2 py-0.5 rounded-full text-xs transition-all"
|
||||||
|
:style="fonctions.includes(fn)
|
||||||
|
? 'background: var(--nav-primary); color: var(--nav-text-on-primary); font-weight: 600;'
|
||||||
|
: 'background: var(--nav-bg-alt); color: var(--nav-text-muted);'"
|
||||||
|
@click="toggleFonction(fn)"
|
||||||
|
>{{ fn }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
v-if="hasActiveFilters"
|
||||||
|
@click="resetFilters"
|
||||||
|
class="mt-2 text-xs"
|
||||||
|
style="color: var(--nav-text-muted); text-decoration: underline;"
|
||||||
|
>✕ Effacer les filtres</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Compteur + Liste fiches -->
|
||||||
|
<div class="px-3 py-2">
|
||||||
|
<div class="text-xs font-bold uppercase tracking-wide mb-2" style="color: var(--nav-text-muted);">
|
||||||
|
{{ filtered.length }} résultat{{ filtered.length > 1 ? 's' : '' }}
|
||||||
|
</div>
|
||||||
|
<div v-if="pending" class="flex items-center justify-center py-8" style="color: var(--nav-text-muted);">
|
||||||
|
Chargement des fiches…
|
||||||
|
</div>
|
||||||
|
<div v-else-if="filtered.length === 0" class="text-center py-8">
|
||||||
|
<p class="text-sm mb-2" style="color: var(--nav-text-muted);">Aucun résultat pour ces filtres.</p>
|
||||||
|
<button @click="resetFilters" class="text-sm underline" style="color: var(--nav-primary-solid);">
|
||||||
|
Effacer les filtres
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div
|
||||||
|
v-for="org in filtered"
|
||||||
|
:key="org.Id"
|
||||||
|
class="block rounded-lg p-3 transition-all cursor-pointer"
|
||||||
|
:style="selectedId === org.Id
|
||||||
|
? 'background: var(--nav-bg-alt); border-left: 3px solid var(--nav-accent);'
|
||||||
|
: 'background: var(--nav-surface); border-left: 3px solid transparent;'"
|
||||||
|
@click="onSelectOrgMobile(org.Id)"
|
||||||
|
>
|
||||||
|
<div class="flex items-start justify-between gap-2">
|
||||||
|
<span class="font-semibold text-sm leading-snug" style="color: var(--nav-text);">{{ org.nom }}</span>
|
||||||
|
<span
|
||||||
|
v-if="org.echelle"
|
||||||
|
class="shrink-0 px-2 py-0.5 rounded-full text-xs font-medium"
|
||||||
|
style="background: var(--nav-bg-alt); color: var(--nav-text-muted);"
|
||||||
|
>{{ org.echelle }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="fonctionsList(org).length" class="mt-1 flex flex-wrap gap-1">
|
||||||
|
<span
|
||||||
|
v-for="fn in fonctionsList(org)"
|
||||||
|
:key="fn"
|
||||||
|
class="px-1.5 py-0.5 rounded text-xs"
|
||||||
|
style="background: var(--nav-bg-alt); color: var(--nav-text-muted);"
|
||||||
|
>{{ fn }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="org.localisation_ville" class="mt-1 text-xs" style="color: var(--nav-text-muted);">
|
||||||
|
{{ org.localisation_ville }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</MobileSheet>
|
||||||
|
</ClientOnly>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- ═══════════════════════════════════════ MODAL FICHE (desktop) -->
|
||||||
|
<FicheModal
|
||||||
|
v-model="ficheModalOpen"
|
||||||
|
:orgId="ficheModalId"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- ═══════════════════════════════════════ BOUTON CHATBOT FLOTTANT (mobile) -->
|
||||||
|
<button
|
||||||
|
class="lg:hidden fixed bottom-6 right-4 z-[1000] flex items-center gap-2 px-4 rounded-full shadow-lg"
|
||||||
|
style="
|
||||||
|
height: 48px;
|
||||||
|
background: var(--nav-primary);
|
||||||
|
opacity: 0.92;
|
||||||
|
color: var(--nav-text-on-primary);
|
||||||
|
box-shadow: 0 4px 16px rgba(26,34,56,0.25);
|
||||||
|
font-family: var(--nav-font);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
"
|
||||||
|
aria-label="Ouvrir l'assistant Chatbot"
|
||||||
|
@click="chatbotOpen = true"
|
||||||
|
>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
|
||||||
|
</svg>
|
||||||
|
<span>Chatbot</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- ═══════════════════════════════════════ CHATBOT BOTTOM SHEET (mobile) -->
|
||||||
|
<ChatbotSheet
|
||||||
|
:modelValue="chatbotOpen"
|
||||||
|
@update:modelValue="chatbotOpen = $event"
|
||||||
|
@highlightOrgs="onHighlightOrgs"
|
||||||
|
/>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { Org } from '~/types/org'
|
||||||
|
|
||||||
|
// ── URL query params sync ─────────────────────────────────────────────────
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const search = ref<string>((route.query.q as string) ?? '')
|
||||||
|
const echelle = ref<string[]>(
|
||||||
|
route.query.echelle
|
||||||
|
? (route.query.echelle as string).split(',').filter(Boolean)
|
||||||
|
: []
|
||||||
|
)
|
||||||
|
const fonctions = ref<string[]>(
|
||||||
|
route.query.fonctions
|
||||||
|
? (route.query.fonctions as string).split(',').filter(Boolean)
|
||||||
|
: []
|
||||||
|
)
|
||||||
|
const territoire = ref<string | null>((route.query.territoire as string) ?? null)
|
||||||
|
const territoireMode = ref<string>(
|
||||||
|
(route.query.mode as string) === 'outremer' ? 'outremer' : 'metropole'
|
||||||
|
)
|
||||||
|
|
||||||
|
const selectedId = ref<number | null>(null)
|
||||||
|
const chatbotOpen = ref(false)
|
||||||
|
const ficheModalOpen = ref(false)
|
||||||
|
const ficheModalId = ref<number | null>(null)
|
||||||
|
const mobileMapView = ref<'metropole' | 'outremer'>('metropole')
|
||||||
|
// Surlignage temporaire (5 sec) suite à une réponse chatbot
|
||||||
|
// → sélectionne le premier ID recommandé sur la carte, puis remet à null
|
||||||
|
let highlightTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
const prevSelectedId = ref<number | null>(null)
|
||||||
|
|
||||||
|
function onHighlightOrgs(ids: (number | string)[]) {
|
||||||
|
if (!ids.length) return
|
||||||
|
const firstId = typeof ids[0] === 'string' ? parseInt(ids[0], 10) : ids[0]
|
||||||
|
if (isNaN(firstId)) return
|
||||||
|
|
||||||
|
// Sauvegarde la sélection courante
|
||||||
|
prevSelectedId.value = selectedId.value
|
||||||
|
selectedId.value = firstId
|
||||||
|
|
||||||
|
if (highlightTimer) clearTimeout(highlightTimer)
|
||||||
|
highlightTimer = setTimeout(() => {
|
||||||
|
// Restaure la sélection précédente (ou null)
|
||||||
|
selectedId.value = prevSelectedId.value
|
||||||
|
prevSelectedId.value = null
|
||||||
|
highlightTimer = null
|
||||||
|
}, 5000)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ref locale barre de recherche mobile (synchronisée avec search via onSearch)
|
||||||
|
const mobileSearch = ref<string>((route.query.q as string) ?? '')
|
||||||
|
|
||||||
|
// Refs vers les instances NavMap (desktop + mobile séparées via deux <ClientOnly>)
|
||||||
|
const navMapRef = ref<any>(null)
|
||||||
|
const navMapMobileRef = ref<any>(null)
|
||||||
|
|
||||||
|
// Sync URL <-> état filtres
|
||||||
|
function syncUrl() {
|
||||||
|
const q: Record<string, string> = {}
|
||||||
|
if (search.value) q.q = search.value
|
||||||
|
if (echelle.value.length) q.echelle = echelle.value.join(',')
|
||||||
|
if (fonctions.value.length) q.fonctions = fonctions.value.join(',')
|
||||||
|
if (territoire.value) q.territoire = territoire.value
|
||||||
|
if (territoireMode.value === 'outremer') q.mode = 'outremer'
|
||||||
|
router.replace({ query: Object.keys(q).length ? q : undefined })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sauvegarde les filtres courants dans sessionStorage pour le bouton retour des fiches
|
||||||
|
function storeFiltersForBack() {
|
||||||
|
if (typeof window === 'undefined') return
|
||||||
|
const q: Record<string, string> = {}
|
||||||
|
if (search.value) q.q = search.value
|
||||||
|
if (echelle.value.length) q.echelle = echelle.value.join(',')
|
||||||
|
if (fonctions.value.length) q.fonctions = fonctions.value.join(',')
|
||||||
|
if (territoire.value) q.territoire = territoire.value
|
||||||
|
if (territoireMode.value === 'outremer') q.mode = 'outremer'
|
||||||
|
const qs = new URLSearchParams(q).toString()
|
||||||
|
sessionStorage.setItem('nav_back_filters', qs)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSearch(v: string) { search.value = v; syncUrl(); storeFiltersForBack() }
|
||||||
|
function onMode(v: string) { territoireMode.value = v; syncUrl(); storeFiltersForBack() }
|
||||||
|
function onEchelle(v: string[]) { echelle.value = v; syncUrl(); storeFiltersForBack() }
|
||||||
|
function onFonctions(v: string[]) { fonctions.value = v; syncUrl(); storeFiltersForBack() }
|
||||||
|
function onTerritoire(v: string | null) { territoire.value = v; syncUrl(); storeFiltersForBack() }
|
||||||
|
|
||||||
|
function onSelectOrg(id: number) {
|
||||||
|
selectedId.value = selectedId.value === id ? null : id
|
||||||
|
// Desktop : ouvrir le modal fiche
|
||||||
|
if (typeof window !== 'undefined' && window.innerWidth >= 1024) {
|
||||||
|
ficheModalId.value = id
|
||||||
|
ficheModalOpen.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tap card mobile → ouvre la fiche détaillée
|
||||||
|
function onSelectOrgMobile(id: number) {
|
||||||
|
selectedId.value = id
|
||||||
|
storeFiltersForBack()
|
||||||
|
router.push(`/fiche/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onHoverOrg(id: number | null) {
|
||||||
|
if (id !== null) selectedId.value = id
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasActiveFilters = computed(() =>
|
||||||
|
!!search.value || echelle.value.length > 0 || fonctions.value.length > 0 || !!territoire.value
|
||||||
|
)
|
||||||
|
|
||||||
|
function resetFilters() {
|
||||||
|
search.value = ''
|
||||||
|
echelle.value = []
|
||||||
|
fonctions.value = []
|
||||||
|
territoire.value = null
|
||||||
|
router.replace({ query: undefined })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tagging compact mobile — toggle direct
|
||||||
|
function toggleEchelle(opt: string) {
|
||||||
|
if (echelle.value.includes(opt)) {
|
||||||
|
onEchelle(echelle.value.filter(v => v !== opt))
|
||||||
|
} else {
|
||||||
|
onEchelle([...echelle.value, opt])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleFonction(fn: string) {
|
||||||
|
if (fonctions.value.includes(fn)) {
|
||||||
|
onFonctions(fonctions.value.filter(f => f !== fn))
|
||||||
|
} else {
|
||||||
|
onFonctions([...fonctions.value, fn])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync recherche depuis app.vue top nav (via URL ?q=)
|
||||||
|
watch(() => route.query.q, (v) => {
|
||||||
|
search.value = (v as string) ?? ''
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Données ───────────────────────────────────────────────────────────────
|
||||||
|
const { data, pending, error } = await useFetch<{ list: Org[]; source: string }>('/api/organisations')
|
||||||
|
|
||||||
|
const orgs = computed<Org[]>(() => data.value?.list ?? [])
|
||||||
|
const dataSource = computed(() => data.value?.source ?? 'nocodb')
|
||||||
|
|
||||||
|
// Fiche aléatoire — réagit au ?random=1
|
||||||
|
watch(() => route.query.random, (v) => {
|
||||||
|
if (v === '1' && orgs.value.length > 0) {
|
||||||
|
const randomOrg = orgs.value[Math.floor(Math.random() * orgs.value.length)]
|
||||||
|
router.replace({ path: `/fiche/${randomOrg.Id}` })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Filtrage côté client ──────────────────────────────────────────────────
|
||||||
|
const filtered = computed<Org[]>(() => {
|
||||||
|
let result = orgs.value
|
||||||
|
|
||||||
|
if (search.value.trim()) {
|
||||||
|
const q = search.value.toLowerCase()
|
||||||
|
result = result.filter(
|
||||||
|
(o) =>
|
||||||
|
o.nom?.toLowerCase().includes(q) ||
|
||||||
|
o.localisation_ville?.toLowerCase().includes(q)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (echelle.value.length) {
|
||||||
|
result = result.filter((o) => o.echelle && echelle.value.includes(o.echelle))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fonctions.value.length) {
|
||||||
|
// Garde les orgs qui matchent au moins 1 fonction sélectionnée
|
||||||
|
result = result.filter((o) => {
|
||||||
|
const orgFns = (o.tags_fonction ?? '').split(',').map((f) => f.trim()).filter(Boolean)
|
||||||
|
return fonctions.value.some((fn) => orgFns.includes(fn))
|
||||||
|
})
|
||||||
|
// Tri par score pondéré : priorité 1 (1er cliqué) = poids le plus fort
|
||||||
|
const n = fonctions.value.length
|
||||||
|
const score = (o: Org) =>
|
||||||
|
fonctions.value.reduce((s, fn, i) => {
|
||||||
|
const fns = (o.tags_fonction ?? '').split(',').map((f) => f.trim()).filter(Boolean)
|
||||||
|
return s + (fns.includes(fn) ? (n - i) : 0)
|
||||||
|
}, 0)
|
||||||
|
result = [...result].sort((a, b) => score(b) - score(a))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (territoire.value) {
|
||||||
|
result = result.filter((o) => o.territoire === territoire.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
|
||||||
|
const DOM_TOM = ['Guadeloupe', 'Martinique', 'Guyane', 'La Réunion', 'Mayotte']
|
||||||
|
const DOM_TOM_LIST = DOM_TOM
|
||||||
|
|
||||||
|
const metropoleOrgs = computed<Org[]>(() =>
|
||||||
|
filtered.value.filter(o => !o.territoire || !DOM_TOM.includes(o.territoire))
|
||||||
|
)
|
||||||
|
|
||||||
|
const outremerOrgs = computed<Org[]>(() => {
|
||||||
|
if (territoire.value && DOM_TOM.includes(territoire.value)) {
|
||||||
|
return filtered.value.filter(o => o.territoire === territoire.value)
|
||||||
|
}
|
||||||
|
return filtered.value.filter(o => o.territoire && DOM_TOM.includes(o.territoire))
|
||||||
|
})
|
||||||
|
|
||||||
|
const outremerCountByDom = computed<Record<string, number>>(() => {
|
||||||
|
const counts: Record<string, number> = {}
|
||||||
|
DOM_TOM.forEach(d => { counts[d] = 0 })
|
||||||
|
filtered.value.forEach(o => {
|
||||||
|
if (o.territoire && DOM_TOM.includes(o.territoire)) {
|
||||||
|
counts[o.territoire] = (counts[o.territoire] ?? 0) + 1
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return counts
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Compteurs ─────────────────────────────────────────────────────────────
|
||||||
|
const ECHELLES = ['National', 'Régional', 'Local'] as const
|
||||||
|
const ECHELLE_LABELS: Record<string, string> = { National: 'Nat', Régional: 'Rég', Local: 'Loc' }
|
||||||
|
const FONCTIONS = ['Juridique', 'Technique', 'Économique', 'Administratif', 'Chantier', 'Comptabilité', 'Développement', 'Formation', "Gestion d'agence", 'Santé mentale'] as const
|
||||||
|
const TERRITOIRES = ['Métropole', 'Guadeloupe', 'Martinique', 'Guyane', 'La Réunion', 'Mayotte']
|
||||||
|
|
||||||
|
const echelleCount = computed<Record<string, number>>(() => {
|
||||||
|
const counts: Record<string, number> = {}
|
||||||
|
ECHELLES.forEach((e) => { counts[e] = 0 })
|
||||||
|
orgs.value.forEach((o) => { if (o.echelle) counts[o.echelle] = (counts[o.echelle] ?? 0) + 1 })
|
||||||
|
return counts
|
||||||
|
})
|
||||||
|
|
||||||
|
const fonctionCount = computed<Record<string, number>>(() => {
|
||||||
|
const counts: Record<string, number> = {}
|
||||||
|
FONCTIONS.forEach((f) => { counts[f] = 0 })
|
||||||
|
orgs.value.forEach((o) => {
|
||||||
|
const fns = (o.tags_fonction ?? '').split(',').map((f) => f.trim()).filter(Boolean)
|
||||||
|
fns.forEach((fn) => { counts[fn] = (counts[fn] ?? 0) + 1 })
|
||||||
|
})
|
||||||
|
return counts
|
||||||
|
})
|
||||||
|
|
||||||
|
const territoireCount = computed<Record<string, number>>(() => {
|
||||||
|
const counts: Record<string, number> = {}
|
||||||
|
TERRITOIRES.forEach((t) => { counts[t] = 0 })
|
||||||
|
orgs.value.forEach((o) => { if (o.territoire) counts[o.territoire] = (counts[o.territoire] ?? 0) + 1 })
|
||||||
|
counts['Métropole'] = orgs.value.filter(o => !o.territoire || !DOM_TOM.includes(o.territoire)).length
|
||||||
|
return counts
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||||
|
function fonctionsList(org: Org): string[] {
|
||||||
|
return (org.tags_fonction ?? '').split(',').map((f) => f.trim()).filter(Boolean).slice(0, 3)
|
||||||
|
}
|
||||||
|
|
||||||
|
useHead({ title: 'AEP — Cartographie de l\'écologie politique architecturale' })
|
||||||
|
</script>
|
||||||
38
pages/rag.vue
Normal file
38
pages/rag.vue
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex flex-col items-center justify-center h-full gap-6" style="background: var(--nav-bg);">
|
||||||
|
<div class="text-center max-w-md px-6">
|
||||||
|
<div
|
||||||
|
class="inline-flex items-center justify-center w-14 h-14 rounded-2xl mb-5"
|
||||||
|
style="background: var(--nav-bg-alt);"
|
||||||
|
>
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="color: var(--nav-text-muted);">
|
||||||
|
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/>
|
||||||
|
<polyline points="3.27 6.96 12 12.01 20.73 6.96"/>
|
||||||
|
<line x1="12" y1="22.08" x2="12" y2="12"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h1 class="text-2xl font-bold mb-3" style="color: var(--nav-text);">RAG — Retrieval Augmented Generation</h1>
|
||||||
|
<p class="text-sm leading-relaxed mb-6" style="color: var(--nav-text-muted);">
|
||||||
|
Une base de connaissances interrogeable par IA — textes, rapports, manifestes et ressources documentaires sur l'architecture d'écologie politique.
|
||||||
|
</p>
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-widest mb-6" style="color: var(--nav-text-muted); opacity: 0.6;">
|
||||||
|
Bientôt disponible
|
||||||
|
</p>
|
||||||
|
<NuxtLink
|
||||||
|
to="/"
|
||||||
|
class="inline-flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-semibold transition-all hover:opacity-80"
|
||||||
|
style="background: var(--nav-primary); color: var(--nav-text-on-primary);"
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" aria-hidden="true">
|
||||||
|
<line x1="19" y1="12" x2="5" y2="12"/>
|
||||||
|
<polyline points="12 19 5 12 12 5"/>
|
||||||
|
</svg>
|
||||||
|
Retour à l'écosystème
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
useHead({ title: 'RAG — AEP (bientôt disponible)' })
|
||||||
|
</script>
|
||||||
389
pages/signaler.vue
Normal file
389
pages/signaler.vue
Normal file
@@ -0,0 +1,389 @@
|
|||||||
|
<template>
|
||||||
|
<div class="signaler-page">
|
||||||
|
<div class="signaler-inner">
|
||||||
|
<NuxtLink to="/" class="back-link">← Retour à la carte</NuxtLink>
|
||||||
|
|
||||||
|
<h1>Signaler un problème</h1>
|
||||||
|
<p class="signaler-subtitle">
|
||||||
|
Un bug, un contenu inapproprié, ou une suggestion ? Décris le problème ci-dessous.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Succès -->
|
||||||
|
<div v-if="success" class="success-block" role="status">
|
||||||
|
<div class="success-icon">✓</div>
|
||||||
|
<h2>Merci pour ton retour !</h2>
|
||||||
|
<p>Le signalement a été envoyé. On s'en occupe.</p>
|
||||||
|
<button type="button" class="btn-secondary" @click="reset">Envoyer un autre signalement</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Formulaire -->
|
||||||
|
<form v-else @submit.prevent="submit" class="signaler-form" novalidate>
|
||||||
|
|
||||||
|
<!-- Catégorie -->
|
||||||
|
<div class="field-group">
|
||||||
|
<fieldset>
|
||||||
|
<legend>Ça concerne</legend>
|
||||||
|
<div class="radio-group">
|
||||||
|
<label
|
||||||
|
v-for="cat in CATEGORIES"
|
||||||
|
:key="cat"
|
||||||
|
class="radio-label"
|
||||||
|
:class="{ active: form.category === cat }"
|
||||||
|
>
|
||||||
|
<input type="radio" :value="cat" v-model="form.category" name="category" />
|
||||||
|
{{ cat }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Description -->
|
||||||
|
<div class="field-group">
|
||||||
|
<label for="description">Description</label>
|
||||||
|
<textarea
|
||||||
|
id="description"
|
||||||
|
v-model="form.description"
|
||||||
|
rows="4"
|
||||||
|
placeholder="Décris le problème..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Email (optionnel) -->
|
||||||
|
<div class="field-group">
|
||||||
|
<label for="email">
|
||||||
|
Ton email
|
||||||
|
<span class="label-hint">(optionnel — pour un suivi)</span>
|
||||||
|
</label>
|
||||||
|
<input id="email" v-model="form.email" type="email" placeholder="ton@email.fr" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="serverError" class="server-error" role="alert">{{ serverError }}</div>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<NuxtLink to="/" class="btn-secondary">Annuler</NuxtLink>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="btn-primary"
|
||||||
|
:disabled="submitting || !form.category || !form.description.trim()"
|
||||||
|
>
|
||||||
|
{{ submitting ? 'Envoi...' : 'Envoyer le signalement' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const CATEGORIES = ['Une fiche', 'Le chatbot', 'La carte', 'Autre'] as const
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
category: '' as string,
|
||||||
|
description: '',
|
||||||
|
email: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const submitting = ref(false)
|
||||||
|
const success = ref(false)
|
||||||
|
const serverError = ref('')
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
serverError.value = ''
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
|
await $fetch('/api/report-general', {
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
category: form.category,
|
||||||
|
description: form.description,
|
||||||
|
email: form.email || undefined,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
success.value = true
|
||||||
|
} catch (e: any) {
|
||||||
|
serverError.value = e?.data?.message || 'Erreur lors de l\'envoi. Réessaie.'
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
Object.assign(form, { category: '', description: '', email: '' })
|
||||||
|
success.value = false
|
||||||
|
serverError.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
useHead({ title: 'Signaler un problème — AEP' })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* ── Layout ─────────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.signaler-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: var(--nav-bg);
|
||||||
|
padding: 1.5rem 1rem 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signaler-inner {
|
||||||
|
max-width: 640px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Retour ──────────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.back-link {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--nav-primary-solid);
|
||||||
|
opacity: 0.7;
|
||||||
|
text-decoration: none;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-link:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── En-tête ─────────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--nav-text);
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signaler-subtitle {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--nav-text-muted);
|
||||||
|
line-height: 1.5;
|
||||||
|
margin: 0 0 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Succès ──────────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.success-block {
|
||||||
|
background: var(--nav-surface);
|
||||||
|
border: 1px solid rgba(26, 34, 56, 0.15);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 2rem 1.5rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-icon {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
background: rgba(26, 34, 56, 0.1);
|
||||||
|
color: var(--nav-text);
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0 auto 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-block h2 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--nav-text);
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-block p {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--nav-text-muted);
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Formulaire ──────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.signaler-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Champ générique ─────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.field-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-group label,
|
||||||
|
.field-group legend {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--nav-text);
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-group fieldset {
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label-hint {
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--nav-text-muted);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
margin-left: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-group input[type="email"],
|
||||||
|
.field-group textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.625rem 0.875rem;
|
||||||
|
border: 1px solid rgba(26, 34, 56, 0.2);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--nav-text);
|
||||||
|
background: var(--nav-surface);
|
||||||
|
font-family: inherit;
|
||||||
|
transition: border-color 0.15s, box-shadow 0.15s;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-group input:focus,
|
||||||
|
.field-group textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--nav-primary-solid);
|
||||||
|
box-shadow: 0 0 0 2px rgba(245, 179, 66, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-group textarea {
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Radio (Catégorie) ────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.radio-group {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radio-label {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
padding: 0.375rem 0.75rem;
|
||||||
|
border: 1px solid rgba(26, 34, 56, 0.2);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--nav-text);
|
||||||
|
background: var(--nav-surface);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radio-label input[type="radio"] {
|
||||||
|
position: absolute;
|
||||||
|
opacity: 0;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radio-label:hover {
|
||||||
|
border-color: var(--nav-primary-solid);
|
||||||
|
background: var(--nav-bg-alt);
|
||||||
|
}
|
||||||
|
|
||||||
|
.radio-label.active {
|
||||||
|
background: var(--nav-primary);
|
||||||
|
border-color: transparent;
|
||||||
|
color: var(--nav-text-on-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Erreur serveur ──────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.server-error {
|
||||||
|
padding: 0.875rem 1rem;
|
||||||
|
background: #fdf0ee;
|
||||||
|
border: 1px solid #e74c3c;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #c0392b;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Actions ──────────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
background: var(--nav-primary);
|
||||||
|
color: var(--nav-text-on-primary);
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
transition: background 0.15s, opacity 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover:not(:disabled) {
|
||||||
|
background: rgba(26, 34, 56, 0.75);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
padding: 0.75rem 1.25rem;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--nav-text-muted);
|
||||||
|
border: 1px solid rgba(26, 34, 56, 0.2);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
transition: border-color 0.15s, color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
border-color: var(--nav-primary-solid);
|
||||||
|
color: var(--nav-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Responsive ──────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.signaler-page {
|
||||||
|
padding: 1rem 0.75rem 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
flex-direction: column-reverse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary,
|
||||||
|
.btn-secondary {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
224
prompt-aep-S5-corrections-post-phase2.md
Normal file
224
prompt-aep-S5-corrections-post-phase2.md
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
## État d'avancement (session 2026-04-15) — LIVRÉE
|
||||||
|
|
||||||
|
- **Fait :** 11/11 retours implémentés, 8 commits `aep-s5` (8ae4be3 → aca6bc8), site 200 OK
|
||||||
|
- **Décisions d'exécution :**
|
||||||
|
- #5 → fallback Resend (pas NocoDB)
|
||||||
|
- #7 → Option A (DOM-TOM row horizontale pleine largeur en bas)
|
||||||
|
- #2 → vanilla touch events (@vueuse/core absent, 0 dep ajoutée)
|
||||||
|
- **Retours post-livraison Jules → session suivante S6 :**
|
||||||
|
- DOM-TOM : à repenser (pile verticale à droite, pas row bas) + bug Réunion disparaît
|
||||||
|
- Onglets : style languette dossier, invisibles en mobile
|
||||||
|
- Mobile : barre recherche superposée filtres, chatbot à remonter, leaflet credit à gérer
|
||||||
|
- Bandeau : pas fluide, label "Soutenir le projet" (pas "transparence IA")
|
||||||
|
- Form proposer : bouton envoyer invisible (BUG critique), réduire min caractères
|
||||||
|
- Chatbot : vérifier compteur tokens
|
||||||
|
- **Prompt S6 :** `0 INBOX/PROMPTS/prompt-aep-S6-corrections-post-S5.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Prompt — AEP Session 5 (corrections post Phase 2 + Session N fusionnée)
|
||||||
|
|
||||||
|
**À lancer :** prochaine session, Opus pilote + Sonnet délégué (ou Sonnet direct si charge légère)
|
||||||
|
**Priorité :** UX polish + onglets multi-projets + report participatif
|
||||||
|
**Créé :** 2026-04-15 (après Phase 2 livrée — 10/10 features, 9 commits)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Contexte
|
||||||
|
|
||||||
|
Site live : https://aep.trans-former.fr/ (nav.trans-former.fr → 301 → aep).
|
||||||
|
Racine projet : `C:\Users\jules\Dropbox\ATIS - IPCJRA\1 PROJETS\TECH - infra VPS, website pro, RAG\nav-carte\`
|
||||||
|
VPS SSH : alias `vps-hetzner`, service `nav-carte.service`, working dir `/opt/nav-carte/.output/`.
|
||||||
|
Stack : Nuxt 3 + Vue 3 + Tailwind + Leaflet.
|
||||||
|
|
||||||
|
**État Phase 2 livrée (session J+1, 2026-04-15) :**
|
||||||
|
- Bugs critiques fixés (chatbot env vars, pins Leaflet ESM default import, redirect nav→aep)
|
||||||
|
- 10 features UX déployées : modal fiche sidebar, fusion outre-mer, CartoDB Positron + layer control + bounds France, bandeau rétractable desktop + FAB mobile, textes corrigés, Liberapay /donate, logo tooltip, header refonte, dark mode toggle, /a-propos CTA, /ajouter-carte placeholder
|
||||||
|
- 9 commits sur main (voir `git log --oneline main`)
|
||||||
|
|
||||||
|
**Questions ouvertes Jules → Pilote (tranchées) :**
|
||||||
|
- Dark mode tuile : switch auto light_all ↔ dark_all CartoDB → oui (retour #9)
|
||||||
|
- Doublon barre recherche header vs sidebar : retirer du header, garder sidebar (retour #1 desktop). Mobile : header doit afficher la barre (retour #1 mobile)
|
||||||
|
- Composants morts (`TerritoireTabs.vue`, `TerritoireToggle.vue`) : supprimer, plus utilisés
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Mission
|
||||||
|
|
||||||
|
Appliquer 11 retours visuels post-déploiement Phase 2 + fusionner Session N (onglets multi-projets) dans le header.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Retours Jules — à implémenter
|
||||||
|
|
||||||
|
### 1. Barre de recherche
|
||||||
|
- **Desktop** : retirer la barre de recherche du header (doublon avec sidebar). Garder uniquement celle de `NavSidebar.vue` à gauche.
|
||||||
|
- **Mobile** : faire apparaître la barre de recherche DANS le header mobile (actuellement absente).
|
||||||
|
|
||||||
|
### 2. Mobile — sheet swipable (Option A validée)
|
||||||
|
Sur `pages/index.vue` mobile (`<lg`) :
|
||||||
|
- Carte en haut, hauteur ~45dvh initialement
|
||||||
|
- En dessous, une **bottom sheet swipable** (à la Google Maps mobile) : glisser vers le haut → la sheet remplit l'écran, la carte se minimise
|
||||||
|
- Pattern : Teleport + drag handle + transition hauteur, stocker état (collapsed / half / full) en ref
|
||||||
|
- Sur full screen sheet : liste des fiches scrollable
|
||||||
|
- Inspi : `ChatbotSheet.vue` existant pour la structure, mais avec drag handle fonctionnel (framer-motion ? ou vueuse `useSwipe` + transform)
|
||||||
|
- Desktop : ne pas toucher (sidebar + modal modal déjà OK)
|
||||||
|
|
||||||
|
### 3. Header — onglets territoire multi-projets (fusion Session N)
|
||||||
|
**Remplacer** la barre de recherche header desktop par **3 onglets horizontaux** centrés :
|
||||||
|
- **Écosystème Entraide Architecture** (actif, actuel = /)
|
||||||
|
- **Agences Inspirantes** (en construction — route `/agences` à créer, affiche "En construction" centré + retour accueil)
|
||||||
|
- **RAG** (en construction — route `/rag` à créer, idem placeholder)
|
||||||
|
|
||||||
|
Design : onglets style tabs (underline actif, gris inactifs + mention "🔒 en construction" small en dessous pour les 2 placeholders).
|
||||||
|
Mobile : même logique en format scroll horizontal ou drawer.
|
||||||
|
|
||||||
|
### 4. Header — retirer "+ Ajouter une carte"
|
||||||
|
Ne fait pas sens pour l'utilisateur. Garder juste `+ Proposer une ressource` à droite. Supprimer aussi la route `/ajouter-carte` créée en S4 (pas utile).
|
||||||
|
|
||||||
|
### 5. Report/modif participatif — nouveau feature
|
||||||
|
Sur `FicheDetail.vue` (composant fiche complet), ajouter un **bouton "Signaler une erreur / proposer une modif"** (icône + texte, en pied de fiche ou header fiche, discret).
|
||||||
|
|
||||||
|
Clic → **form inline** (petit panneau qui se déplie) :
|
||||||
|
- Champ texte (max 500 chars) : "Que proposes-tu de modifier ou signaler ?"
|
||||||
|
- Champ email (obligatoire, validation regex)
|
||||||
|
- Bouton "Envoyer"
|
||||||
|
|
||||||
|
Backend : créer `server/api/report.post.ts` qui :
|
||||||
|
- Rate limit 5/IP/jour (pattern `rateLimitJson` existant)
|
||||||
|
- Insère dans NocoDB nouvelle table `reports` (à créer côté NocoDB — demander token/table_id à Jules OU créer l'API endpoint et laisser Jules créer la table via UI NocoDB après)
|
||||||
|
- Champs NocoDB : `fiche_id` (number), `message` (longtext), `email` (text), `submitted_at` (datetime), `ip_hash` (text), `status` (select: new/treated/ignored) default new
|
||||||
|
|
||||||
|
Alternative si table NocoDB pas créable en autonome : fallback envoi email via Resend (clé `RESEND_API_KEY` dans `.env` VPS) vers `EMAIL_JULES` (jules@trans-former.fr).
|
||||||
|
|
||||||
|
### 6. Commentaires — texte d'intro
|
||||||
|
Dans `CommentSection.vue` ou `CommentForm.vue`, ajouter/modifier le texte préalable aux commentaires :
|
||||||
|
> "Les commentaires servent à confirmer ou attester de la fiabilité des services des organismes référencés."
|
||||||
|
|
||||||
|
Le placer en intro, italique ou subtil, avant les commentaires existants.
|
||||||
|
|
||||||
|
### 7. DOM-TOM — superposition horizontale + navigation
|
||||||
|
Actuellement : les 5 mini-cartes DOM-TOM sont en **grille verticale** dans l'encart droit 1/3 (desktop) et ne sont pas navigables (dragging désactivé).
|
||||||
|
|
||||||
|
Cible :
|
||||||
|
- **Layout** : superposer horizontalement (5 colonnes étroites) au lieu de 5 lignes. Dans l'encart 1/3 droit, ça veut dire un bandeau plus large ou une scrollbox horizontale
|
||||||
|
- **Actually** — repenser : si "superposer horizontalement" veut dire **côte-à-côte**, l'encart 1/3 est trop étroit. Proposer 2 options à Jules au démarrage de S5 :
|
||||||
|
- Option A : DOM-TOM en row horizontale en BAS de la carte Métropole (pleine largeur, hauteur ~140px), plus en encart droit
|
||||||
|
- Option B : encart droit garde la disposition mais devient scrollable horizontal
|
||||||
|
- **Navigation** : activer `dragging: true`, `scrollWheelZoom: true` sur les mini-cartes DOM-TOM dans `OutremerMap.vue` (actuellement désactivés ligne 89-95). Le cadre est trop serré → augmenter le zoom initial ou élargir les bounds par DOM.
|
||||||
|
|
||||||
|
### 8. Layer control carte
|
||||||
|
**À supprimer** — retour Jules #8 : CartoDB Positron est super comme ça, pas besoin de superposer les couches. Retirer `L.control.layers(baseMaps, {}, ...)` dans `NavMap.vue`. Garder uniquement la tuile CartoDB Positron par défaut.
|
||||||
|
|
||||||
|
Note fun : Jules a remarqué que les noms de régions sont en anglais sur CartoDB → garder tel quel, ça l'amuse.
|
||||||
|
|
||||||
|
### 9. Dark mode — switch tuile carte
|
||||||
|
Implémenter le watcher manquant : quand `dark` class active sur `html`, switcher la tuile CartoDB de `light_all` vers `dark_all` (`https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png`).
|
||||||
|
|
||||||
|
Dans `NavMap.vue` : watch sur un store/ref du theme (créer un composable `useTheme()` si pas existant), removeLayer / addLayer dynamique. Idem dans `OutremerMap.vue`.
|
||||||
|
|
||||||
|
### 10. Bandeau bas — inverser la logique
|
||||||
|
**Actuel** : bandeau déployé par défaut, se replie après 3 sec sans mouvement souris, se redéploie au hover (pattern jugé "chelou" et bloquant).
|
||||||
|
|
||||||
|
**Cible** :
|
||||||
|
- Bandeau **replié par défaut** (barre fine ~30px)
|
||||||
|
- Au survol souris (zone bas de l'écran ou bandeau lui-même) → se déploie, contenu visible
|
||||||
|
- Quand souris s'éloigne → se replie immédiatement (pas de timer)
|
||||||
|
- Opacité du bandeau déployé : **70%** (plus transparent qu'actuellement)
|
||||||
|
|
||||||
|
### 11. Bouton "Soutenir le projet" — recentré
|
||||||
|
Dans le bandeau bas déployé, le bouton Liberapay est actuellement mal aligné. Le recentrer :
|
||||||
|
- Vertical : décaler d'un saut de ligne vers le bas (plus centré dans la hauteur du bandeau déployé)
|
||||||
|
- Horizontal : centré ou aligné avec le reste
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Règles d'exécution
|
||||||
|
|
||||||
|
- Tu peux déléguer à des sous-agents Task (`subagent_type: general-purpose`, `model: sonnet`) pour paralléliser
|
||||||
|
- Commits atomiques par feature : `feat(aep-s5): <feature>`
|
||||||
|
- Tester après chaque deploy : `curl -sI https://aep.trans-former.fr/ | head -3` → 200
|
||||||
|
- `systemctl is-active nav-carte` doit rester `active`
|
||||||
|
- Mise à jour `JOURNAL-V2.md` avec section "Session 5 (date)"
|
||||||
|
- **NE PAS TOUCHER à `.env` VPS ni `.env.production` local** (patchés manuellement, sync cassé)
|
||||||
|
|
||||||
|
## Checkpoints requis pour Jules
|
||||||
|
|
||||||
|
- **Retour #5 report** : au moment de créer la table NocoDB, demander à Jules le token + l'ID de la table (ou lui demander de la créer en UI puis te passer l'ID). Alternative fallback Resend si impossible.
|
||||||
|
- **Retour #7 DOM-TOM layout** : présenter les 2 options A/B avant d'implémenter.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dette technique deploy.sh (hors scope S5 — session dédiée)
|
||||||
|
|
||||||
|
> Ces items ne bloquent pas S5 mais doivent être traités avant le prochain deploy.sh pour éviter d'écraser la config VPS patchée manuellement.
|
||||||
|
|
||||||
|
**Ordre d'exécution recommandé :**
|
||||||
|
|
||||||
|
### P0 — deploy.sh (à corriger en premier, avant tout redeploy)
|
||||||
|
|
||||||
|
- **Cible incorrecte** : `deploy.sh` pointe vers `/opt/nav-carte/` alors que la cible correcte est `/opt/nav-carte/.output/`. Incohérence découverte en S3b — à corriger immédiatement.
|
||||||
|
- **Garde diff `.env`** : ajouter dans `deploy.sh` un diff automatique entre `.env.production` local et `.env` VPS AVANT écrasement, ou passer le scp `.env` en option `--env` (skip par défaut). Actuellement le script écrase le `.env` VPS sans avertissement.
|
||||||
|
|
||||||
|
### P1 — Synchroniser les `.env` (bloquant avant prochain deploy complet)
|
||||||
|
|
||||||
|
- **`.env.production` local obsolète** : contient 4 vars + token périmé. Le `.env` VPS a été patché manuellement (Mistral, NocoDB, 7 aliases `NUXT_*`). Si `deploy.sh` tourne sans correction P0, il écrase le VPS → chatbot + API cassés. **Action requise :** copier le `.env` VPS (`ssh vps-hetzner cat /opt/nav-carte/.env`) dans `.env.production` local, puis synchroniser.
|
||||||
|
- **`nuxt.config.ts`** : simplifier le double mapping variables `.env` (ex : `MISTRAL_API_KEY` + alias `NUXT_MISTRAL_API_KEY` — redondant, source d'erreurs silencieuses).
|
||||||
|
|
||||||
|
### P1 — Migration terminologique (à planifier après deploy stabilisé)
|
||||||
|
|
||||||
|
- **`/opt/nav-carte/` → `/opt/aep/`** : renommer le répertoire VPS + mettre à jour `deploy.sh` + tous les chemins.
|
||||||
|
- **`nav-carte.service` → `aep.service`** : rebrand du service systemd (stop, rename unit file, daemon-reload, enable, start). Attention : coordonner avec le changement de répertoire.
|
||||||
|
|
||||||
|
### P1 — Monitoring (à ajouter après migration)
|
||||||
|
|
||||||
|
- **Sonde Uptime Kuma** : créer une sonde HTTP sur `https://aep.trans-former.fr/api/stats` — alerte sur code 5xx. Configurer dans l'instance Uptime Kuma existante sur le VPS.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Déploiement (rappel)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd "<racine nav-carte>"
|
||||||
|
npm run build # si EBUSY sur .nuxt : rm -rf .nuxt .output && sleep 2 && retry
|
||||||
|
tar -czf - .output | ssh vps-hetzner "rm -rf /tmp/nav-output && mkdir /tmp/nav-output && tar -xzf - -C /tmp/nav-output && rm -rf /opt/nav-carte/.output && mv /tmp/nav-output/.output /opt/nav-carte/.output && systemctl restart nav-carte"
|
||||||
|
curl -sI https://aep.trans-former.fr/ | head -3
|
||||||
|
```
|
||||||
|
|
||||||
|
## Critères de fin
|
||||||
|
|
||||||
|
- [ ] 11 retours implémentés et déployés
|
||||||
|
- [ ] Route `/agences` + `/rag` créées (placeholders "en construction")
|
||||||
|
- [ ] Route `/ajouter-carte` supprimée
|
||||||
|
- [ ] Composants morts (`TerritoireTabs.vue`, `TerritoireToggle.vue`) supprimés
|
||||||
|
- [ ] API `/api/report` ou fallback email fonctionnel
|
||||||
|
- [ ] Site 200 OK, service active
|
||||||
|
- [ ] JOURNAL-V2.md à jour
|
||||||
|
|
||||||
|
## Modèle recommandé
|
||||||
|
|
||||||
|
- Opus pilote pour arbitrages (checkpoint #7 DOM-TOM layout, décision NocoDB vs Resend pour #5)
|
||||||
|
- Sonnet délégué pour l'exécution UX pure (#1, #3, #4, #6, #8, #9, #10, #11)
|
||||||
|
- Sonnet sur sheet swipable mobile (#2) — assez technique mais cadré
|
||||||
|
- Sonnet sur report feature (#5) avec fallback
|
||||||
|
|
||||||
|
## Durée estimée
|
||||||
|
|
||||||
|
2-3h selon délégation + décision NocoDB.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Autocritique
|
||||||
|
|
||||||
|
**✓ Solide :**
|
||||||
|
- Retours groupés par type (UX polish / nouvelle feature / bug fix)
|
||||||
|
- #7 et #5 ont checkpoints explicites → pas de fonçage aveugle
|
||||||
|
- Session N fusionnée dans #3, plus de prompt séparé nécessaire
|
||||||
|
- État live + commits référencés pour éviter régression
|
||||||
|
|
||||||
|
**⚠ Faiblesses :**
|
||||||
|
- #5 report : dépend création table NocoDB (bloquant si Jules pas dispo au moment du checkpoint)
|
||||||
|
- #2 sheet swipable : pas de lib précise recommandée, l'agent devra choisir (`@vueuse/core useSwipe` ou implémentation manuelle transform)
|
||||||
|
- #10 bandeau inversé : peut nécessiter rework plus profond du composant `BandeauBas.vue` selon son architecture actuelle — pas inspecté
|
||||||
|
|
||||||
|
**Autonomie : 7/10** (2 checkpoints Jules : #5 NocoDB table, #7 DOM-TOM layout A/B).
|
||||||
165
prompt-aep-S7-logo-fiches-outremer.md
Normal file
165
prompt-aep-S7-logo-fiches-outremer.md
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
# Prompt AEP — S7 : Logo, fiches cliquables, outre-mer accordéon
|
||||||
|
|
||||||
|
**Projet :** `1 PROJETS/TECH - infra VPS, website pro, RAG/nav-carte/`
|
||||||
|
**Site live :** `aep.trans-former.fr`
|
||||||
|
**Journal :** `nav-carte/JOURNAL-V2.md`
|
||||||
|
**Invocation :** `/atis-dev AEP`
|
||||||
|
**Commit de référence desktop :** `aca6bc8` (S5)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## État d'avancement (fin S6, 2026-04-16)
|
||||||
|
|
||||||
|
Déployé et fonctionnel :
|
||||||
|
- Header mobile : sous-titre "Écosystème d'entraide Architecture" sous AEP
|
||||||
|
- Onglets Métropole/Outre-mer en pills colorées (fond primary actif)
|
||||||
|
- Hamburger menu z-[1200] — fonctionne partout (Métropole + Outre-mer)
|
||||||
|
- Pinch-to-zoom page désactivé (zoom carte Leaflet préservé)
|
||||||
|
- MobileSheet : compteur "99 fiches" supprimé du header (reste sous les tags)
|
||||||
|
- Page /contribuer : scroll mobile fixé (overflow-y: auto)
|
||||||
|
- Outre-mer : 5 cartes en colonne verticale
|
||||||
|
|
||||||
|
Bugs restants :
|
||||||
|
- Fiches ne s'ouvrent pas au clic (ni mobile ni desktop)
|
||||||
|
- Outre-mer difficile à lire en petit format horizontal
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Corrections à faire (par priorité)
|
||||||
|
|
||||||
|
### P0 — Bug critique : fiches ne s'ouvrent pas au clic
|
||||||
|
|
||||||
|
**Symptôme :** Sur mobile ET desktop, cliquer sur un marqueur carte ne déploie pas la fiche. On voit les marqueurs, les popups Leaflet s'ouvrent (mobile), mais le FicheModal (desktop) ne s'ouvre jamais et sur mobile la sélection ne fait rien de visible.
|
||||||
|
|
||||||
|
**Contexte technique :**
|
||||||
|
|
||||||
|
Desktop (`pages/index.vue`) :
|
||||||
|
```js
|
||||||
|
function onSelectOrg(id: number) {
|
||||||
|
selectedId.value = selectedId.value === id ? null : id
|
||||||
|
if (typeof window !== 'undefined' && window.innerWidth >= 1024) {
|
||||||
|
ficheModalId.value = id
|
||||||
|
ficheModalOpen.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Le `FicheModal` est bien dans le template :
|
||||||
|
```html
|
||||||
|
<FicheModal v-model="ficheModalOpen" :orgId="ficheModalId" />
|
||||||
|
```
|
||||||
|
|
||||||
|
Le `NavMap.vue` émet `select-org` sur clic marker :
|
||||||
|
```js
|
||||||
|
marker.on('click', () => emit('select-org', org.Id))
|
||||||
|
```
|
||||||
|
|
||||||
|
**En S6, on a ajouté un conditionnel** sur `bindPopup` (mobile uniquement) dans NavMap.vue :
|
||||||
|
```js
|
||||||
|
if (typeof window !== 'undefined' && window.innerWidth < 1024) {
|
||||||
|
marker.bindPopup(...)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Hypothèses à investiguer :**
|
||||||
|
1. Le popup Leaflet intercepte-t-il le `click` event même quand `bindPopup` n'est pas appelé ?
|
||||||
|
2. Le `clusterGroup` (MarkerClusterGroup) intercepte-t-il les clics ?
|
||||||
|
3. L'API `/api/fiche/[id]` retourne-t-elle bien les données ? Tester : `curl https://aep.trans-former.fr/api/fiche/1`
|
||||||
|
4. Le `FicheModal` reçoit-il bien `orgId` et `modelValue=true` ? Ajouter un `console.log` temporaire dans `onSelectOrg`.
|
||||||
|
5. Sur mobile, `onSelectOrgMobile` ne fait que toggler `selectedId` — il n'ouvre rien. Faut-il naviguer vers `/fiche/[id]` ?
|
||||||
|
|
||||||
|
**Approche suggérée :**
|
||||||
|
- Tester l'API fiche en curl d'abord
|
||||||
|
- Ajouter des console.log dans onSelectOrg / onSelectOrgMobile pour confirmer que l'event arrive
|
||||||
|
- Sur desktop : vérifier que FicheModal se monte (DevTools → Elements)
|
||||||
|
- Sur mobile : décider du comportement attendu (naviguer vers `/fiche/[id]` ou ouvrir une sheet ?)
|
||||||
|
|
||||||
|
### P1 — Logo "AEP" dans le carré + header renommé
|
||||||
|
|
||||||
|
**Actuellement (app.vue) :**
|
||||||
|
```html
|
||||||
|
<div class="w-7 h-7 rounded-lg flex items-center justify-center shrink-0"
|
||||||
|
style="background: var(--nav-primary-solid);">
|
||||||
|
<span class="font-bold text-sm" style="color: var(--nav-text-on-primary);">A</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<span class="font-bold text-base tracking-tight leading-tight">AEP</span>
|
||||||
|
<span class="text-xs leading-tight lg:hidden">Écosystème d'entraide Architecture</span>
|
||||||
|
<span class="text-xs leading-tight hidden lg:inline">Architecture d'Écologie Politique</span>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Attendu :**
|
||||||
|
- Le carré affiche "AEP" (pas juste "A") — agrandir légèrement le carré si nécessaire (w-9 h-7 ?)
|
||||||
|
- Le texte à côté du logo : "Architecture d'Écologie Politique" (sur mobile ET desktop)
|
||||||
|
- Supprimer le sous-titre "Écosystème d'entraide Architecture" (c'est le nom de la carte, pas du site)
|
||||||
|
- Clic sur le logo → renvoie vers le blog `https://trans-former.fr` (pas vers `/`)
|
||||||
|
|
||||||
|
### P2 — Outre-mer mobile : accordéon
|
||||||
|
|
||||||
|
**Actuellement :** 5 mini-cartes empilées en colonne, chacune 120px de haut. Difficile à lire.
|
||||||
|
|
||||||
|
**Attendu :** Système d'accordéon :
|
||||||
|
- Par défaut : les 5 DOM-TOM sont des lignes compactes (label + compteur fiches, ~40px)
|
||||||
|
- Clic sur une ligne → la déplie (~1/3 de l'écran, ~33vh) et affiche la carte Leaflet
|
||||||
|
- Les autres lignes restent repliées
|
||||||
|
- Re-clic → replie
|
||||||
|
- Une seule carte dépliée à la fois
|
||||||
|
|
||||||
|
**Implémentation suggérée :**
|
||||||
|
```html
|
||||||
|
<div v-for="dom in DOM_TOM" :key="dom.name" class="outremer-accordion-item">
|
||||||
|
<!-- Header cliquable (toujours visible) -->
|
||||||
|
<div class="accordion-header" @click="toggleDom(dom.name)">
|
||||||
|
<span>{{ dom.name }}</span>
|
||||||
|
<span>{{ orgCounts[dom.name] }} fiches</span>
|
||||||
|
<chevron :expanded="expandedDom === dom.name" />
|
||||||
|
</div>
|
||||||
|
<!-- Carte (visible si déplié) -->
|
||||||
|
<div v-show="expandedDom === dom.name" class="accordion-body" style="height: 33vh;">
|
||||||
|
<div :ref="..." class="outremer-map" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
Script : ajouter `const expandedDom = ref<string | null>(null)` + `toggleDom(name)`.
|
||||||
|
|
||||||
|
**Attention :** les cartes Leaflet doivent être initialisées APRÈS le dépliage (ou invalidateSize() appelé), sinon les tiles ne s'affichent pas correctement.
|
||||||
|
|
||||||
|
**Desktop :** garder le layout colonne actuel (pas d'accordéon, les 5 cartes restent visibles dans la sidebar droite de 220px).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fichiers clés
|
||||||
|
|
||||||
|
| Fichier | Rôle |
|
||||||
|
|---------|------|
|
||||||
|
| `app.vue` | Header global, logo, hamburger menu |
|
||||||
|
| `pages/index.vue` | Page carte, onSelectOrg, FicheModal |
|
||||||
|
| `components/NavMap.vue` | Carte Leaflet Métropole, markers, clics |
|
||||||
|
| `components/FicheModal.vue` | Modal fiche desktop (Teleport to body) |
|
||||||
|
| `components/OutremerMap.vue` | Mini-cartes DOM-TOM |
|
||||||
|
| `components/MobileSheet.vue` | Bottom sheet swipable mobile |
|
||||||
|
| `server/api/fiche/[id].get.ts` | API proxy NocoDB pour une fiche |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Deploy
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build local
|
||||||
|
cd nav-carte && npm run build
|
||||||
|
|
||||||
|
# Tar + SCP + extract dans .output/ (PAS dans /opt/nav-carte/ racine !)
|
||||||
|
tar -czf /tmp/nav-carte-output.tar.gz -C .output .
|
||||||
|
scp -P 4422 /tmp/nav-carte-output.tar.gz vps-hetzner:/tmp/
|
||||||
|
scp -P 4422 .env.production vps-hetzner:/opt/nav-carte/.env
|
||||||
|
|
||||||
|
# Sur le VPS
|
||||||
|
ssh -p 4422 vps-hetzner "rm -rf /opt/nav-carte/.output/server /opt/nav-carte/.output/public && cd /opt/nav-carte/.output && tar -xzf /tmp/nav-carte-output.tar.gz && rm /tmp/nav-carte-output.tar.gz && systemctl restart nav-carte"
|
||||||
|
|
||||||
|
# Vérifier (port 3333, pas 3000 !)
|
||||||
|
ssh -p 4422 vps-hetzner "curl -s -o /dev/null -w 'HTTP %{http_code}' http://localhost:3333/"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Piège :** le service écoute sur le port **3333** (pas 3000). Le Caddyfile fait `reverse_proxy localhost:3333`.
|
||||||
BIN
public/og-default.png
Normal file
BIN
public/og-default.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
331
server/api/chatbot.post.ts
Normal file
331
server/api/chatbot.post.ts
Normal file
@@ -0,0 +1,331 @@
|
|||||||
|
/**
|
||||||
|
* POST /api/chatbot
|
||||||
|
*
|
||||||
|
* Chatbot recherche sémantique — Mistral Small
|
||||||
|
* Spec : F §7 (endpoint), F §8 (rate limit), E-spec §6 (détails chatbot)
|
||||||
|
*
|
||||||
|
* Flow :
|
||||||
|
* 1. Rate limit : 10 req/IP/jour (JSON fichier, SHA-256)
|
||||||
|
* 2. Circuit breaker : budget 20€/mois
|
||||||
|
* 3. Fetch top-N fiches (keyword match sur nom+description+fonctions)
|
||||||
|
* 4. Appel Mistral Small avec contexte JSON compact
|
||||||
|
* 5. Parse JSON → { reponse_texte, fiches_recommandees }
|
||||||
|
* 6. Log stats_usage
|
||||||
|
*
|
||||||
|
* Réponse 200 : { reponse_texte, fiches_recommandees: [{ id, nom, explication }] }
|
||||||
|
* Réponse 429 : rate limit dépassé
|
||||||
|
* Réponse 503 : budget IA épuisé
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { checkRateLimitJson } from '~/server/utils/rateLimitJson'
|
||||||
|
import { checkBudget, calcCoutMistralSmall } from '~/server/utils/circuitBreaker'
|
||||||
|
|
||||||
|
// ── Types ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface OrgRow {
|
||||||
|
Id: number
|
||||||
|
nom: string
|
||||||
|
description_enrichie?: string | null
|
||||||
|
description_user?: string | null
|
||||||
|
tags_fonction?: string | null
|
||||||
|
echelle?: string | null
|
||||||
|
localisation_ville?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FicheReco {
|
||||||
|
id: number
|
||||||
|
nom: string
|
||||||
|
explication: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MistralResponse {
|
||||||
|
reponse_texte: string
|
||||||
|
fiches_recommandees: FicheReco[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── System prompt Mistral Small ────────────────────────────────────────────────
|
||||||
|
// Construit depuis E-spec-frontend.md §Détails chatbot + §Prompt système
|
||||||
|
// (F-spec §3 concerne Mistral Nemo enrichissement — ne pas confondre)
|
||||||
|
|
||||||
|
const SYSTEM_PROMPT = `Tu es un assistant engagé au service de la transition écologique des pratiques architecturales. Tu accèdes à AEP (Architecture d'Écologie Politique) — Écosystème Entraide, une base de données collaborative qui référence les acteurs de l'écologie politique appliquée à l'architecture et au territoire (organisations, outils, ressources) pour les architectes en France.
|
||||||
|
|
||||||
|
RÈGLES ABSOLUES :
|
||||||
|
1. Tu ne peux recommander QUE des organisations présentes dans le contexte ci-dessous.
|
||||||
|
2. Ne jamais inventer d'organisation absente du contexte.
|
||||||
|
3. Cite chaque organisation recommandée par son nom exact et son identifiant id.
|
||||||
|
4. Si le contexte ne contient aucune organisation pertinente, dis-le honnêtement.
|
||||||
|
5. Réponses concises par défaut (200 mots max). Si l'usager demande explicitement plus de détail, tu peux développer.
|
||||||
|
6. Retourne UNIQUEMENT un objet JSON valide, sans texte avant ou après.
|
||||||
|
7. Si la question est hors du champ architecture / écologie / territoire / transition, recadre poliment vers le périmètre de la carte.
|
||||||
|
|
||||||
|
FORMAT DE SORTIE :
|
||||||
|
{
|
||||||
|
"reponse_texte": "Ta réponse en prose (max 200 mots), orientée vers les besoins de l'architecte",
|
||||||
|
"fiches_recommandees": [
|
||||||
|
{ "id": 123, "explication": "Pourquoi cette fiche répond à la question (1-2 phrases max)" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
CONTEXTE — Organisations disponibles dans la base NAV :
|
||||||
|
{{FICHES_JSON}}`
|
||||||
|
|
||||||
|
// ── Recherche par mots-clés ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function scoreOrg(org: OrgRow, keywords: string[]): number {
|
||||||
|
if (keywords.length === 0) return 1
|
||||||
|
const haystack = [
|
||||||
|
org.nom,
|
||||||
|
org.description_enrichie,
|
||||||
|
org.description_user,
|
||||||
|
org.tags_fonction,
|
||||||
|
org.localisation_ville,
|
||||||
|
org.echelle,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ')
|
||||||
|
.toLowerCase()
|
||||||
|
|
||||||
|
return keywords.reduce((score, kw) => {
|
||||||
|
return score + (haystack.includes(kw) ? 1 : 0)
|
||||||
|
}, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractKeywords(question: string): string[] {
|
||||||
|
return question
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^\w\sàâäéèêëîïôùûüç-]/g, ' ')
|
||||||
|
.split(/\s+/)
|
||||||
|
.filter((w) => w.length >= 3)
|
||||||
|
.slice(0, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Fetch fiches depuis NocoDB ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function fetchApprovedOrgs(config: {
|
||||||
|
nocodbUrl: string
|
||||||
|
nocodbToken: string
|
||||||
|
orgTableId: string
|
||||||
|
}): Promise<OrgRow[]> {
|
||||||
|
const { nocodbUrl, nocodbToken, orgTableId } = config
|
||||||
|
const url = `${nocodbUrl}/api/v2/tables/${orgTableId}/records`
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await $fetch<{ list: OrgRow[] }>(url, {
|
||||||
|
headers: { 'xc-token': nocodbToken },
|
||||||
|
query: {
|
||||||
|
where: '(moderation_status,eq,approved)',
|
||||||
|
limit: 200,
|
||||||
|
fields: 'Id,nom,description_enrichie,description_user,tags_fonction,echelle,localisation_ville',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return res?.list ?? []
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[chatbot] Erreur fetch NocoDB:', (e as Error).message)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Log stats_usage ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function logUsage(params: {
|
||||||
|
nocodbUrl: string
|
||||||
|
nocodbToken: string
|
||||||
|
statsTableId: string
|
||||||
|
tokensIn: number
|
||||||
|
tokensOut: number
|
||||||
|
coutEur: number
|
||||||
|
}) {
|
||||||
|
const { nocodbUrl, nocodbToken, statsTableId, tokensIn, tokensOut, coutEur } = params
|
||||||
|
const logUrl = `${nocodbUrl}/api/v2/tables/${statsTableId}/records`
|
||||||
|
try {
|
||||||
|
await $fetch(logUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'xc-token': nocodbToken, 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: 'mistral-small-latest',
|
||||||
|
endpoint: 'chatbot',
|
||||||
|
tokens_in: tokensIn,
|
||||||
|
tokens_out: tokensOut,
|
||||||
|
cout_eur: coutEur,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
orga_id: null,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[chatbot] Log stats_usage échoué (non bloquant):', (e as Error).message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Handler principal ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
|
||||||
|
// 1. IP (proxy-aware)
|
||||||
|
const ip =
|
||||||
|
getHeader(event, 'x-forwarded-for')?.split(',')[0].trim() ||
|
||||||
|
event.node.req.socket?.remoteAddress ||
|
||||||
|
'0.0.0.0'
|
||||||
|
|
||||||
|
// 2. Rate limit : 10 req/IP/jour (JSON + SHA-256)
|
||||||
|
const allowed = checkRateLimitJson(ip, 'chatbot', 10)
|
||||||
|
if (!allowed) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 429,
|
||||||
|
statusMessage: 'Limite de 10 questions par jour atteinte.',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Lire le body
|
||||||
|
const body = await readBody(event)
|
||||||
|
const question: string = (body?.question ?? '').trim()
|
||||||
|
const filters: { fonction?: string; echelle?: string } = body?.filters ?? {}
|
||||||
|
|
||||||
|
if (!question || question.length < 3) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: 'Question trop courte.',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Circuit breaker budget
|
||||||
|
const statsTableId = process.env.STATS_TABLE_ID || 'mbbq7n47ixy19mc'
|
||||||
|
const budget = await checkBudget({
|
||||||
|
nocodbUrl: config.nocodbUrl as string,
|
||||||
|
nocodbToken: config.nocodbToken as string,
|
||||||
|
statsTableId,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (budget.blocked) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 503,
|
||||||
|
statusMessage: 'Budget IA mensuel épuisé — réouverture le 1er du mois prochain.',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Fetch fiches et scoring par mots-clés
|
||||||
|
const allOrgs = await fetchApprovedOrgs({
|
||||||
|
nocodbUrl: config.nocodbUrl as string,
|
||||||
|
nocodbToken: config.nocodbToken as string,
|
||||||
|
orgTableId: config.orgTableId as string,
|
||||||
|
})
|
||||||
|
|
||||||
|
const keywords = extractKeywords(question)
|
||||||
|
|
||||||
|
// Filtrage optionnel par taxonomie si filtres fournis
|
||||||
|
let filtered = allOrgs
|
||||||
|
if (filters.fonction) {
|
||||||
|
filtered = filtered.filter((o) =>
|
||||||
|
(o.tags_fonction ?? '').toLowerCase().includes(filters.fonction!.toLowerCase()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (filters.echelle) {
|
||||||
|
filtered = filtered.filter((o) =>
|
||||||
|
(o.echelle ?? '').toLowerCase() === filters.echelle!.toLowerCase(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Score + top 20
|
||||||
|
const scored = filtered
|
||||||
|
.map((o) => ({ org: o, score: scoreOrg(o, keywords) }))
|
||||||
|
.sort((a, b) => b.score - a.score)
|
||||||
|
.slice(0, 20)
|
||||||
|
.map((x) => x.org)
|
||||||
|
|
||||||
|
// Contexte JSON compact pour le prompt
|
||||||
|
const fichesContext = scored.map((o) => ({
|
||||||
|
id: o.Id,
|
||||||
|
nom: o.nom,
|
||||||
|
fonctions: o.tags_fonction ?? '',
|
||||||
|
echelle: o.echelle ?? '',
|
||||||
|
description: (o.description_enrichie ?? o.description_user ?? '').slice(0, 200),
|
||||||
|
ville: o.localisation_ville ?? '',
|
||||||
|
}))
|
||||||
|
|
||||||
|
const systemPrompt = SYSTEM_PROMPT.replace(
|
||||||
|
'{{FICHES_JSON}}',
|
||||||
|
JSON.stringify(fichesContext, null, 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
// 6. Appel Mistral Small
|
||||||
|
const mistralApiKey = config.mistralApiKey as string
|
||||||
|
|
||||||
|
if (!mistralApiKey) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 500,
|
||||||
|
statusMessage: 'Clé API Mistral manquante.',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
let mistralRaw: string
|
||||||
|
let tokensIn = 0
|
||||||
|
let tokensOut = 0
|
||||||
|
|
||||||
|
try {
|
||||||
|
const mistralRes = await $fetch<{
|
||||||
|
choices: { message: { content: string } }[]
|
||||||
|
usage?: { prompt_tokens: number; completion_tokens: number }
|
||||||
|
}>('https://api.mistral.ai/v1/chat/completions', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${mistralApiKey}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: 'mistral-small-latest',
|
||||||
|
temperature: 0.3,
|
||||||
|
max_tokens: 600,
|
||||||
|
response_format: { type: 'json_object' },
|
||||||
|
messages: [
|
||||||
|
{ role: 'system', content: systemPrompt },
|
||||||
|
{ role: 'user', content: question },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
mistralRaw = mistralRes.choices?.[0]?.message?.content ?? '{}'
|
||||||
|
tokensIn = mistralRes.usage?.prompt_tokens ?? 0
|
||||||
|
tokensOut = mistralRes.usage?.completion_tokens ?? 0
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error('[chatbot] Erreur Mistral Small:', e?.message ?? e)
|
||||||
|
throw createError({
|
||||||
|
statusCode: 502,
|
||||||
|
statusMessage: 'Erreur appel IA — réessaie dans quelques instants.',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. Parse JSON
|
||||||
|
let parsed: MistralResponse
|
||||||
|
try {
|
||||||
|
const raw = JSON.parse(mistralRaw)
|
||||||
|
parsed = {
|
||||||
|
reponse_texte: raw.reponse_texte ?? 'Je n\'ai pas pu analyser ta demande.',
|
||||||
|
fiches_recommandees: (raw.fiches_recommandees ?? []).map((f: any) => {
|
||||||
|
const org = scored.find((o) => o.Id === f.id)
|
||||||
|
return {
|
||||||
|
id: f.id,
|
||||||
|
nom: org?.nom ?? f.nom ?? `Fiche #${f.id}`,
|
||||||
|
explication: f.explication ?? '',
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
parsed = {
|
||||||
|
reponse_texte: 'Je n\'ai pas pu analyser ta demande correctement.',
|
||||||
|
fiches_recommandees: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8. Log usage (non bloquant)
|
||||||
|
const coutEur = calcCoutMistralSmall(tokensIn, tokensOut)
|
||||||
|
logUsage({
|
||||||
|
nocodbUrl: config.nocodbUrl as string,
|
||||||
|
nocodbToken: config.nocodbToken as string,
|
||||||
|
statsTableId,
|
||||||
|
tokensIn,
|
||||||
|
tokensOut,
|
||||||
|
coutEur,
|
||||||
|
})
|
||||||
|
|
||||||
|
return parsed
|
||||||
|
})
|
||||||
22
server/api/comment/[orgId].get.ts
Normal file
22
server/api/comment/[orgId].get.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
/**
|
||||||
|
* GET /api/comment/[orgId]
|
||||||
|
* Retourne les commentaires publiés (published=true) pour une fiche.
|
||||||
|
* Triés par submitted_at ASC (chronologique).
|
||||||
|
*/
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
const orgId = getRouterParam(event, 'orgId')
|
||||||
|
|
||||||
|
if (!orgId || isNaN(Number(orgId))) {
|
||||||
|
throw createError({ statusCode: 400, message: 'Identifiant invalide' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const tableId = config.commentTableId
|
||||||
|
const url = `${config.nocodbUrl}/api/v2/tables/${tableId}/records?where=(orga_id,eq,${orgId})~and(published,eq,true)&sort=submitted_at&limit=50`
|
||||||
|
|
||||||
|
const data: any = await $fetch(url, {
|
||||||
|
headers: { 'xc-token': config.nocodbToken },
|
||||||
|
}).catch(() => ({ list: [] }))
|
||||||
|
|
||||||
|
return { list: data?.list ?? [] }
|
||||||
|
})
|
||||||
183
server/api/comment/index.post.ts
Normal file
183
server/api/comment/index.post.ts
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
/**
|
||||||
|
* POST /api/comment
|
||||||
|
* Soumettre un commentaire sur une fiche.
|
||||||
|
*
|
||||||
|
* Corps attendu :
|
||||||
|
* { orga_id: number, contenu: string, auteur_pseudo?: string, auteur_email?: string }
|
||||||
|
*
|
||||||
|
* Flux :
|
||||||
|
* 1. Rate limit Redis : ≤ 5 commentaires / IP / jour (Sonnet 3)
|
||||||
|
* 2. Validation basique
|
||||||
|
* 3. Filtre éthique Mistral Nemo (timeout 2s — fallback pending si timeout/erreur)
|
||||||
|
* 4. INSERT NocoDB table avis
|
||||||
|
* 5. Retourner { ok: true, status: 'approved' | 'pending' }
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { checkRateLimit } from '~/server/utils/rateLimit'
|
||||||
|
|
||||||
|
interface CommentBody {
|
||||||
|
orga_id: number
|
||||||
|
contenu: string
|
||||||
|
auteur_pseudo?: string
|
||||||
|
auteur_email?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MistralFilterResult {
|
||||||
|
safe: boolean
|
||||||
|
category: string | null
|
||||||
|
reason: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function filtreEthique(
|
||||||
|
contenu: string,
|
||||||
|
mistralKey: string
|
||||||
|
): Promise<MistralFilterResult> {
|
||||||
|
const controller = new AbortController()
|
||||||
|
const timeout = setTimeout(() => controller.abort(), 2000)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response: any = await $fetch('https://api.mistral.ai/v1/chat/completions', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${mistralKey}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: 'open-mistral-nemo',
|
||||||
|
temperature: 0.0,
|
||||||
|
max_tokens: 100,
|
||||||
|
response_format: { type: 'json_object' },
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: 'system',
|
||||||
|
content: `Tu es un modèle de modération de contenu. Tu analyses des commentaires soumis sur une plateforme professionnelle française dédiée aux architectes. Tu dois détecter les contenus problématiques.
|
||||||
|
|
||||||
|
CONTENUS À SIGNALER (safe=false) :
|
||||||
|
- Propos racistes, antisémites, islamophobes, xénophobes
|
||||||
|
- Propos sexistes, misogynes, homophobes, transphobes, LGBTQIA-phobes
|
||||||
|
- Propos validistes (mépris des personnes handicapées)
|
||||||
|
- Diffamation personnelle nominative (accusations graves sans fondement contre une personne identifiable)
|
||||||
|
- Spam publicitaire explicite (lien commercial non sollicité + texte promotionnel)
|
||||||
|
- Harcèlement ciblé, menaces explicites
|
||||||
|
|
||||||
|
CONTENUS À AUTORISER (safe=true) :
|
||||||
|
- Critiques d'organisations ou de services (même sévères), sans attaque personnelle
|
||||||
|
- Partages d'expériences professionnelles négatives factuelles
|
||||||
|
- Désaccords et débats professionnels
|
||||||
|
- Signalement d'erreurs ou d'informations inexactes
|
||||||
|
|
||||||
|
RÈGLES :
|
||||||
|
1. En cas de doute, favorise safe=true (la liberté d'expression professionnelle prime).
|
||||||
|
2. Le champ "category" est obligatoire si safe=false.
|
||||||
|
3. Le champ "reason" est une phrase courte en français (max 20 mots).
|
||||||
|
4. Retourne UNIQUEMENT un objet JSON valide.
|
||||||
|
|
||||||
|
FORMAT DE SORTIE :
|
||||||
|
{
|
||||||
|
"safe": true | false,
|
||||||
|
"category": "racisme" | "sexisme" | "homophobie" | "antisémitisme" | "lgbtqia-phobie" | "validisme" | "diffamation" | "spam" | "harcelement" | null,
|
||||||
|
"reason": "string (max 20 mots)" | null
|
||||||
|
}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: `COMMENTAIRE À ANALYSER :\n\n"${contenu}"\n\nAnalyse ce commentaire et retourne le JSON de modération.`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
// @ts-ignore — signal pas dans les types $fetch mais fonctionne en pratique
|
||||||
|
signal: controller.signal,
|
||||||
|
})
|
||||||
|
|
||||||
|
clearTimeout(timeout)
|
||||||
|
const raw = response?.choices?.[0]?.message?.content ?? '{}'
|
||||||
|
const parsed = JSON.parse(raw)
|
||||||
|
return {
|
||||||
|
safe: parsed.safe !== false,
|
||||||
|
category: parsed.category ?? null,
|
||||||
|
reason: parsed.reason ?? null,
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
clearTimeout(timeout)
|
||||||
|
// Timeout ou erreur réseau → fallback pending
|
||||||
|
return { safe: false, category: null, reason: 'timeout_ou_erreur_mistral' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
|
||||||
|
// Rate limit : 5 commentaires / IP / jour
|
||||||
|
const ip =
|
||||||
|
getHeader(event, 'x-forwarded-for')?.split(',')[0].trim() ||
|
||||||
|
event.node.req.socket?.remoteAddress ||
|
||||||
|
'0.0.0.0'
|
||||||
|
|
||||||
|
const allowed = await checkRateLimit(ip, 'comment', 5)
|
||||||
|
if (!allowed) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 429,
|
||||||
|
statusMessage: 'Trop de commentaires. Réessaie demain.',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await readBody<CommentBody>(event)
|
||||||
|
|
||||||
|
// Validation basique
|
||||||
|
if (!body?.orga_id || !body?.contenu) {
|
||||||
|
throw createError({ statusCode: 400, message: 'Champs requis manquants : orga_id, contenu' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const contenu = body.contenu.trim()
|
||||||
|
if (contenu.length < 10 || contenu.length > 500) {
|
||||||
|
throw createError({ statusCode: 400, message: 'Le commentaire doit faire entre 10 et 500 caractères' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filtre éthique Mistral Nemo
|
||||||
|
let published = false
|
||||||
|
let safeCheck = 'pending'
|
||||||
|
let safeReason: string | null = null
|
||||||
|
|
||||||
|
if (config.mistralApiKey) {
|
||||||
|
const result = await filtreEthique(contenu, config.mistralApiKey)
|
||||||
|
published = result.safe
|
||||||
|
safeCheck = result.safe ? 'safe' : (result.reason === 'timeout_ou_erreur_mistral' ? 'pending' : 'flagged')
|
||||||
|
safeReason = result.reason
|
||||||
|
} else {
|
||||||
|
// Clé Mistral absente → fallback pending (TODO configurer MISTRAL_API_KEY)
|
||||||
|
safeCheck = 'pending'
|
||||||
|
safeReason = 'mistral_key_manquante'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insertion NocoDB
|
||||||
|
const tableId = config.commentTableId
|
||||||
|
const url = `${config.nocodbUrl}/api/v2/tables/${tableId}/records`
|
||||||
|
|
||||||
|
const record = await $fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'xc-token': config.nocodbToken,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
orga_id: body.orga_id,
|
||||||
|
contenu,
|
||||||
|
auteur_pseudo: body.auteur_pseudo || null,
|
||||||
|
auteur_email: body.auteur_email || null,
|
||||||
|
published,
|
||||||
|
safe_check: safeCheck,
|
||||||
|
safe_reason: safeReason,
|
||||||
|
submitted_at: new Date().toISOString(),
|
||||||
|
}),
|
||||||
|
}).catch((err: any) => {
|
||||||
|
throw createError({ statusCode: 502, message: 'Erreur NocoDB lors de l\'insertion du commentaire' })
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
status: published ? 'approved' : 'pending',
|
||||||
|
message: published
|
||||||
|
? 'Commentaire publié. Merci pour ta contribution !'
|
||||||
|
: 'Commentaire reçu et en cours de modération.',
|
||||||
|
}
|
||||||
|
})
|
||||||
26
server/api/fiche/[id].get.ts
Normal file
26
server/api/fiche/[id].get.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
/**
|
||||||
|
* GET /api/fiche/[id]
|
||||||
|
* Proxy NocoDB — retourne la fiche complète avec tous les champs V2.
|
||||||
|
* Utilisé par pages/fiche/[id].vue (SSR).
|
||||||
|
*/
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
const id = getRouterParam(event, 'id')
|
||||||
|
|
||||||
|
if (!id || isNaN(Number(id))) {
|
||||||
|
throw createError({ statusCode: 400, message: 'Identifiant invalide' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = `${config.nocodbUrl}/api/v2/tables/${config.orgTableId}/records?where=(Id,eq,${id})&limit=1`
|
||||||
|
|
||||||
|
const data: any = await $fetch(url, {
|
||||||
|
headers: { 'xc-token': config.nocodbToken },
|
||||||
|
}).catch(() => null)
|
||||||
|
|
||||||
|
const record = data?.list?.[0] ?? null
|
||||||
|
if (!record) {
|
||||||
|
throw createError({ statusCode: 404, message: 'Organisation non trouvée' })
|
||||||
|
}
|
||||||
|
|
||||||
|
return record
|
||||||
|
})
|
||||||
94
server/api/report-general.post.ts
Normal file
94
server/api/report-general.post.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
/**
|
||||||
|
* POST /api/report-general
|
||||||
|
*
|
||||||
|
* Signalement général (bug, contenu inapproprié, suggestion)
|
||||||
|
*
|
||||||
|
* Body : { category: string, description: string, email?: string }
|
||||||
|
* Rate limit : 5/IP/jour
|
||||||
|
* Envoi vers jules@trans-former.fr via Resend API
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { checkRateLimitJson } from '~/server/utils/rateLimitJson'
|
||||||
|
|
||||||
|
const EMAIL_JULES = process.env.EMAIL_JULES || 'jules@trans-former.fr'
|
||||||
|
|
||||||
|
const VALID_CATEGORIES = ['Une fiche', 'Le chatbot', 'La carte', 'Autre'] as const
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
// 1. IP
|
||||||
|
const ip =
|
||||||
|
getHeader(event, 'x-forwarded-for')?.split(',')[0].trim() ||
|
||||||
|
event.node.req.socket?.remoteAddress ||
|
||||||
|
'0.0.0.0'
|
||||||
|
|
||||||
|
// 2. Rate limit 5/IP/jour
|
||||||
|
const allowed = checkRateLimitJson(ip, 'report-general', 5)
|
||||||
|
if (!allowed) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 429,
|
||||||
|
statusMessage: 'Limite de 5 signalements par jour atteinte.',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Lire le body
|
||||||
|
const body = await readBody(event)
|
||||||
|
const category: string = (body?.category ?? '').trim()
|
||||||
|
const description: string = (body?.description ?? '').trim()
|
||||||
|
const email: string = (body?.email ?? '').trim()
|
||||||
|
|
||||||
|
// 4. Validation
|
||||||
|
if (!VALID_CATEGORIES.includes(category as any)) {
|
||||||
|
throw createError({ statusCode: 400, statusMessage: 'Catégorie invalide.' })
|
||||||
|
}
|
||||||
|
if (!description || description.length < 5 || description.length > 500) {
|
||||||
|
throw createError({ statusCode: 400, statusMessage: 'Description requise (5-500 caractères).' })
|
||||||
|
}
|
||||||
|
if (email) {
|
||||||
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||||
|
if (!emailRegex.test(email)) {
|
||||||
|
throw createError({ statusCode: 400, statusMessage: 'Email invalide.' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Envoi via Resend
|
||||||
|
const resendApiKey = process.env.RESEND_API_KEY
|
||||||
|
if (!resendApiKey) {
|
||||||
|
console.error('[report-general] RESEND_API_KEY manquante')
|
||||||
|
throw createError({ statusCode: 500, statusMessage: 'Configuration email manquante.' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const submittedAt = new Date().toLocaleString('fr-FR', { timeZone: 'Europe/Paris' })
|
||||||
|
|
||||||
|
try {
|
||||||
|
await $fetch('https://api.resend.com/emails', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${resendApiKey}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
from: 'AEP Signalement <noreply@trans-former.fr>',
|
||||||
|
to: EMAIL_JULES,
|
||||||
|
subject: `[AEP] Signalement — ${category}`,
|
||||||
|
html: `
|
||||||
|
<h2>Signalement AEP — ${category}</h2>
|
||||||
|
<p><strong>Date :</strong> ${submittedAt}</p>
|
||||||
|
<p><strong>Catégorie :</strong> ${category}</p>
|
||||||
|
${email ? `<p><strong>Email expéditeur :</strong> ${email}</p>` : '<p><em>Pas d\'email fourni</em></p>'}
|
||||||
|
<p><strong>Description :</strong></p>
|
||||||
|
<blockquote style="border-left:3px solid #ccc;padding-left:12px;color:#555;">
|
||||||
|
${description.replace(/\n/g, '<br/>')}
|
||||||
|
</blockquote>
|
||||||
|
`,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error('[report-general] Erreur Resend:', e?.message ?? e)
|
||||||
|
throw createError({
|
||||||
|
statusCode: 502,
|
||||||
|
statusMessage: 'Erreur envoi email — réessaie dans quelques instants.',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true, message: 'Signalement envoyé, merci !' }
|
||||||
|
})
|
||||||
94
server/api/report.post.ts
Normal file
94
server/api/report.post.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
/**
|
||||||
|
* POST /api/report
|
||||||
|
*
|
||||||
|
* Signalement d'erreur / proposition de modification sur une fiche
|
||||||
|
* Fallback Resend (pas de table NocoDB créée)
|
||||||
|
*
|
||||||
|
* Body : { fiche_id: number, message: string, email: string }
|
||||||
|
* Rate limit : 5/IP/jour
|
||||||
|
* Envoi vers jules@trans-former.fr via Resend API
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { checkRateLimitJson } from '~/server/utils/rateLimitJson'
|
||||||
|
|
||||||
|
const EMAIL_JULES = process.env.EMAIL_JULES || 'jules@trans-former.fr'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
// 1. IP
|
||||||
|
const ip =
|
||||||
|
getHeader(event, 'x-forwarded-for')?.split(',')[0].trim() ||
|
||||||
|
event.node.req.socket?.remoteAddress ||
|
||||||
|
'0.0.0.0'
|
||||||
|
|
||||||
|
// 2. Rate limit 5/IP/jour
|
||||||
|
const allowed = checkRateLimitJson(ip, 'report', 5)
|
||||||
|
if (!allowed) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 429,
|
||||||
|
statusMessage: 'Limite de 5 signalements par jour atteinte.',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Lire le body
|
||||||
|
const body = await readBody(event)
|
||||||
|
const fiche_id: number = Number(body?.fiche_id ?? 0)
|
||||||
|
const message: string = (body?.message ?? '').trim()
|
||||||
|
const email: string = (body?.email ?? '').trim()
|
||||||
|
|
||||||
|
// 4. Validation
|
||||||
|
if (!fiche_id || fiche_id <= 0) {
|
||||||
|
throw createError({ statusCode: 400, statusMessage: 'fiche_id invalide.' })
|
||||||
|
}
|
||||||
|
if (!message || message.length < 5 || message.length > 500) {
|
||||||
|
throw createError({ statusCode: 400, statusMessage: 'Message requis (5-500 caractères).' })
|
||||||
|
}
|
||||||
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||||
|
if (!email || !emailRegex.test(email)) {
|
||||||
|
throw createError({ statusCode: 400, statusMessage: 'Email invalide.' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Envoi via Resend
|
||||||
|
const resendApiKey = process.env.RESEND_API_KEY
|
||||||
|
if (!resendApiKey) {
|
||||||
|
console.error('[report] RESEND_API_KEY manquante')
|
||||||
|
throw createError({ statusCode: 500, statusMessage: 'Configuration email manquante.' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const submittedAt = new Date().toLocaleString('fr-FR', { timeZone: 'Europe/Paris' })
|
||||||
|
|
||||||
|
try {
|
||||||
|
await $fetch('https://api.resend.com/emails', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${resendApiKey}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
from: 'AEP Signalement <noreply@trans-former.fr>',
|
||||||
|
to: EMAIL_JULES,
|
||||||
|
subject: `[AEP] Signalement fiche #${fiche_id}`,
|
||||||
|
html: `
|
||||||
|
<h2>Signalement fiche AEP #${fiche_id}</h2>
|
||||||
|
<p><strong>Date :</strong> ${submittedAt}</p>
|
||||||
|
<p><strong>Email expéditeur :</strong> ${email}</p>
|
||||||
|
<p><strong>Message :</strong></p>
|
||||||
|
<blockquote style="border-left:3px solid #ccc;padding-left:12px;color:#555;">
|
||||||
|
${message.replace(/\n/g, '<br/>')}
|
||||||
|
</blockquote>
|
||||||
|
<hr/>
|
||||||
|
<p style="font-size:12px;color:#999;">
|
||||||
|
Voir la fiche : <a href="https://aep.trans-former.fr/fiche/${fiche_id}">https://aep.trans-former.fr/fiche/${fiche_id}</a>
|
||||||
|
</p>
|
||||||
|
`,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error('[report] Erreur Resend:', e?.message ?? e)
|
||||||
|
throw createError({
|
||||||
|
statusCode: 502,
|
||||||
|
statusMessage: 'Erreur envoi email — réessaie dans quelques instants.',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true, message: 'Signalement envoyé, merci !' }
|
||||||
|
})
|
||||||
106
server/api/stats.get.ts
Normal file
106
server/api/stats.get.ts
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
/**
|
||||||
|
* GET /api/stats
|
||||||
|
*
|
||||||
|
* Statistiques d'usage pour le bandeau bas (transparence IA + compteurs semaine).
|
||||||
|
*
|
||||||
|
* Query params :
|
||||||
|
* periode = "mois" (défaut) | "semaine"
|
||||||
|
*
|
||||||
|
* Payload retourné :
|
||||||
|
* cout_mois_eur number — somme cout_eur du mois courant
|
||||||
|
* budget_mois 20 — budget fixe mensuel
|
||||||
|
* tokens_mois number — somme tokens_in + tokens_out du mois
|
||||||
|
* co2_kg number — estimation CO2 (tokens × 0.000001 × 0.052 kgCO2eq/kWh mix RTE)
|
||||||
|
* requetes_mois number — count rows du mois
|
||||||
|
* fiches_semaine number — orgs approuvées créées dans les 7 derniers jours
|
||||||
|
* requetes_chatbot_semaine number — count stats_usage endpoint=chatbot dans les 7j
|
||||||
|
*/
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
const nocodbUrl = config.nocodbUrl as string
|
||||||
|
const nocodbToken = config.nocodbToken as string
|
||||||
|
const statsTableId = (config.statsTableId as string) || 'mbbq7n47ixy19mc'
|
||||||
|
const orgTableId = config.orgTableId as string
|
||||||
|
const nocoBaseId = process.env.NOCODB_BASE_ID || 'p_nav_v2'
|
||||||
|
|
||||||
|
const headers = { 'xc-token': nocodbToken }
|
||||||
|
|
||||||
|
// ── Dates ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const now = new Date()
|
||||||
|
const moisDebut = new Date(now.getFullYear(), now.getMonth(), 1).toISOString().slice(0, 10)
|
||||||
|
const semaineDebut = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10)
|
||||||
|
|
||||||
|
// ── Helpers fetch NocoDB ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function fetchStats(where: string): Promise<{ list: Record<string, any>[] }> {
|
||||||
|
try {
|
||||||
|
return await $fetch(`${nocodbUrl}/api/v1/db/data/noco/${nocoBaseId}/${statsTableId}`, {
|
||||||
|
headers,
|
||||||
|
query: { where, limit: 1000, fields: 'cout_eur,tokens_in,tokens_out,endpoint,created_at' },
|
||||||
|
}) as { list: Record<string, any>[] }
|
||||||
|
} catch {
|
||||||
|
return { list: [] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchOrgsRecentes(since: string): Promise<number> {
|
||||||
|
if (!orgTableId) return 0
|
||||||
|
try {
|
||||||
|
const res = await $fetch<{ pageInfo?: { totalRows?: number } }>(
|
||||||
|
`${nocodbUrl}/api/v1/db/data/noco/${nocoBaseId}/${orgTableId}`,
|
||||||
|
{
|
||||||
|
headers,
|
||||||
|
query: {
|
||||||
|
where: `(moderation_status,eq,approved)~and(created_at,gte,${since})`,
|
||||||
|
limit: 1,
|
||||||
|
fields: 'Id',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return (res as any)?.pageInfo?.totalRows ?? 0
|
||||||
|
} catch {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Fetch parallèle ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const [statsMois, statsSemaine, fichesAjoutees] = await Promise.all([
|
||||||
|
fetchStats(`(created_at,gte,${moisDebut})`),
|
||||||
|
fetchStats(`(created_at,gte,${semaineDebut})`),
|
||||||
|
fetchOrgsRecentes(semaineDebut),
|
||||||
|
])
|
||||||
|
|
||||||
|
// ── Agrégation mois ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
let cout_mois_eur = 0
|
||||||
|
let tokens_mois = 0
|
||||||
|
const requetes_mois = statsMois.list.length
|
||||||
|
|
||||||
|
for (const row of statsMois.list) {
|
||||||
|
cout_mois_eur += Number(row.cout_eur ?? 0)
|
||||||
|
tokens_mois += Number(row.tokens_in ?? 0) + Number(row.tokens_out ?? 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const co2_kg = tokens_mois * 0.000001 * 0.052
|
||||||
|
|
||||||
|
// ── Agrégation semaine chatbot ───────────────────────────────────────────
|
||||||
|
|
||||||
|
const requetes_chatbot_semaine = statsSemaine.list.filter(
|
||||||
|
(r) => (r.endpoint ?? '') === 'chatbot'
|
||||||
|
).length
|
||||||
|
|
||||||
|
// ── Réponse ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
return {
|
||||||
|
cout_mois_eur: Math.round(cout_mois_eur * 1000) / 1000,
|
||||||
|
budget_mois: 20,
|
||||||
|
tokens_mois,
|
||||||
|
co2_kg: Math.round(co2_kg * 1e6) / 1e6,
|
||||||
|
requetes_mois,
|
||||||
|
fiches_semaine: fichesAjoutees,
|
||||||
|
requetes_chatbot_semaine,
|
||||||
|
}
|
||||||
|
})
|
||||||
241
server/api/submit/index.post.ts
Normal file
241
server/api/submit/index.post.ts
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
/**
|
||||||
|
* POST /api/submit
|
||||||
|
*
|
||||||
|
* Soumission d'une nouvelle organisation par un utilisateur.
|
||||||
|
* - Validation Zod côté serveur
|
||||||
|
* - Rate limit Redis : 3 soumissions / IP / jour
|
||||||
|
* - Géocodage Nominatim (optionnel — fallback silencieux)
|
||||||
|
* - INSERT NocoDB : moderation_status=pending, ai_processed=false
|
||||||
|
*
|
||||||
|
* Réponse 201 : { submissionId, message }
|
||||||
|
* Réponse 422 : erreurs Zod
|
||||||
|
* Réponse 429 : rate limit dépassé
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { checkRateLimit } from '~/server/utils/rateLimit'
|
||||||
|
|
||||||
|
// ── Schéma Zod ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const FONCTIONS = [
|
||||||
|
'Juridique',
|
||||||
|
'Technique',
|
||||||
|
'Économique',
|
||||||
|
'Administratif',
|
||||||
|
'Chantier',
|
||||||
|
'Comptabilité',
|
||||||
|
'Développement',
|
||||||
|
'Formation',
|
||||||
|
'Gestion d\'agence',
|
||||||
|
'Santé mentale',
|
||||||
|
] as const
|
||||||
|
|
||||||
|
const ECHELLES = ['National', 'Régional', 'Local'] as const
|
||||||
|
|
||||||
|
const TERRITOIRES = [
|
||||||
|
'Métropole',
|
||||||
|
'Guadeloupe',
|
||||||
|
'Martinique',
|
||||||
|
'Guyane',
|
||||||
|
'La Réunion',
|
||||||
|
'Mayotte',
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export const SubmitSchema = z.object({
|
||||||
|
nom: z.string().min(3, 'Minimum 3 caractères').max(150, 'Maximum 150 caractères').trim(),
|
||||||
|
url: z
|
||||||
|
.string()
|
||||||
|
.url('URL invalide')
|
||||||
|
.optional()
|
||||||
|
.or(z.literal(''))
|
||||||
|
.transform((v) => v || undefined),
|
||||||
|
description_user: z
|
||||||
|
.string()
|
||||||
|
.min(50, 'Minimum 50 caractères')
|
||||||
|
.max(500, 'Maximum 500 caractères')
|
||||||
|
.trim(),
|
||||||
|
echelle: z.enum(ECHELLES, { errorMap: () => ({ message: 'Échelle invalide' }) }),
|
||||||
|
fonctions: z
|
||||||
|
.array(z.enum(FONCTIONS))
|
||||||
|
.min(1, 'Sélectionne au moins une fonction')
|
||||||
|
.max(5, 'Maximum 5 fonctions'),
|
||||||
|
territoire: z.enum(TERRITOIRES, { errorMap: () => ({ message: 'Territoire invalide' }) }),
|
||||||
|
localisation_ville: z
|
||||||
|
.string()
|
||||||
|
.max(100)
|
||||||
|
.optional()
|
||||||
|
.transform((v) => v?.trim() || undefined),
|
||||||
|
submitted_by_email: z
|
||||||
|
.string()
|
||||||
|
.email('Email invalide')
|
||||||
|
.optional()
|
||||||
|
.or(z.literal(''))
|
||||||
|
.transform((v) => v || undefined),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type SubmitInput = z.infer<typeof SubmitSchema>
|
||||||
|
|
||||||
|
// ── Géocodage Nominatim ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function geocodeVille(
|
||||||
|
ville: string,
|
||||||
|
): Promise<{ lat: number; lon: number } | null> {
|
||||||
|
try {
|
||||||
|
const url = `https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(ville)}&format=json&limit=1&countrycodes=fr`
|
||||||
|
const results: any[] = await $fetch(url, {
|
||||||
|
headers: { 'User-Agent': 'NAV/2.0 contact@trans-former.fr' },
|
||||||
|
timeout: 5000,
|
||||||
|
})
|
||||||
|
if (results.length > 0) {
|
||||||
|
return { lat: parseFloat(results[0].lat), lon: parseFloat(results[0].lon) }
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Fallback silencieux — fiche stockée sans coordonnées
|
||||||
|
console.warn('[submit] Géocodage Nominatim échoué pour :', ville)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Handler principal ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
// 1. Récupérer l'IP (proxy-aware)
|
||||||
|
const ip =
|
||||||
|
getHeader(event, 'x-forwarded-for')?.split(',')[0].trim() ||
|
||||||
|
event.node.req.socket?.remoteAddress ||
|
||||||
|
'0.0.0.0'
|
||||||
|
|
||||||
|
// 2. Rate limit : 3 soumissions / IP / jour
|
||||||
|
const allowed = await checkRateLimit(ip, 'submit', 3)
|
||||||
|
if (!allowed) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 429,
|
||||||
|
statusMessage: 'Trop de soumissions. Réessaie demain.',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Lire et valider le body
|
||||||
|
const body = await readBody(event)
|
||||||
|
const parsed = SubmitSchema.safeParse(body)
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 422,
|
||||||
|
statusMessage: 'Validation échouée',
|
||||||
|
data: parsed.error.flatten().fieldErrors,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = parsed.data
|
||||||
|
|
||||||
|
// 4. Géocodage optionnel
|
||||||
|
let latitude: number | null = null
|
||||||
|
let longitude: number | null = null
|
||||||
|
|
||||||
|
if (data.localisation_ville) {
|
||||||
|
const coords = await geocodeVille(data.localisation_ville)
|
||||||
|
if (coords) {
|
||||||
|
latitude = coords.lat
|
||||||
|
longitude = coords.lon
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. INSERT NocoDB
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
const nocodbUrl = config.nocodbUrl
|
||||||
|
const nocodbToken = config.nocodbToken
|
||||||
|
const orgTableId = config.orgTableId
|
||||||
|
const nocoBaseId = config.nocodbBase
|
||||||
|
|
||||||
|
const payload: Record<string, unknown> = {
|
||||||
|
nom: data.nom,
|
||||||
|
url: data.url || null,
|
||||||
|
description_user: data.description_user,
|
||||||
|
echelle: data.echelle,
|
||||||
|
tags_fonction: data.fonctions.join(','),
|
||||||
|
territoire: data.territoire,
|
||||||
|
localisation_ville: data.localisation_ville || null,
|
||||||
|
submitted_by_email: data.submitted_by_email || null,
|
||||||
|
moderation_status: 'pending',
|
||||||
|
scrape_status: data.url ? 'pending' : 'no_link',
|
||||||
|
ai_processed: false,
|
||||||
|
submitted_at: new Date().toISOString(),
|
||||||
|
latitude,
|
||||||
|
longitude,
|
||||||
|
}
|
||||||
|
|
||||||
|
// NocoDB v1 endpoint
|
||||||
|
const insertUrl = `${nocodbUrl}/api/v1/db/data/noco/${nocoBaseId}/${orgTableId}`
|
||||||
|
|
||||||
|
let insertedRecord: any
|
||||||
|
try {
|
||||||
|
insertedRecord = await $fetch(insertUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'xc-token': nocodbToken,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error('[submit] Erreur NocoDB insert:', e?.message ?? e)
|
||||||
|
throw createError({
|
||||||
|
statusCode: 502,
|
||||||
|
statusMessage: 'Erreur serveur — réessaie dans quelques instants.',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const submissionId = insertedRecord?.Id ?? insertedRecord?.id ?? null
|
||||||
|
|
||||||
|
// 6. Notification email Jules (fire-and-forget — n'impacte pas la réponse)
|
||||||
|
notifyJules(config, data, submissionId).catch((e) =>
|
||||||
|
console.warn('[submit] Email notification échouée:', e?.message ?? e),
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 201,
|
||||||
|
submissionId,
|
||||||
|
message: 'Ta fiche est en cours de traitement.',
|
||||||
|
trackingUrl: submissionId
|
||||||
|
? `https://nav.trans-former.fr/suivi/${submissionId}`
|
||||||
|
: null,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
async function notifyJules(
|
||||||
|
config: ReturnType<typeof useRuntimeConfig>,
|
||||||
|
data: SubmitInput,
|
||||||
|
submissionId: number | null,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!config.resendApiKey) return
|
||||||
|
|
||||||
|
const nocoAdminUrl = `http://localhost:8070`
|
||||||
|
const body = {
|
||||||
|
from: 'AEP Carte <contact@trans-former.fr>',
|
||||||
|
to: [config.emailJules],
|
||||||
|
subject: `[AEP] Nouvelle fiche soumise : ${data.nom}`,
|
||||||
|
html: `
|
||||||
|
<p><strong>Nouvelle fiche en attente de modération.</strong></p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Nom :</strong> ${data.nom}</li>
|
||||||
|
<li><strong>URL :</strong> ${data.url ?? '—'}</li>
|
||||||
|
<li><strong>Échelle :</strong> ${data.echelle}</li>
|
||||||
|
<li><strong>Territoire :</strong> ${data.territoire}</li>
|
||||||
|
<li><strong>Fonctions :</strong> ${data.fonctions.join(', ')}</li>
|
||||||
|
<li><strong>Description :</strong> ${data.description_user}</li>
|
||||||
|
${data.submitted_by_email ? `<li><strong>Email soumetteur :</strong> ${data.submitted_by_email}</li>` : ''}
|
||||||
|
${submissionId ? `<li><strong>ID NocoDB :</strong> ${submissionId}</li>` : ''}
|
||||||
|
</ul>
|
||||||
|
<p><a href="${nocoAdminUrl}">Ouvrir NocoDB pour valider</a></p>
|
||||||
|
`,
|
||||||
|
}
|
||||||
|
|
||||||
|
await $fetch('https://api.resend.com/emails', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${config.resendApiKey}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
})
|
||||||
|
}
|
||||||
16
server/routes/api/avis.post.ts
Normal file
16
server/routes/api/avis.post.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
const body = await readBody(event)
|
||||||
|
const url = `${config.nocodbUrl}/api/v2/tables/${config.avisTableId}/records`
|
||||||
|
|
||||||
|
const data = await $fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'xc-token': config.nocodbToken,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
})
|
||||||
|
|
||||||
|
return data
|
||||||
|
})
|
||||||
13
server/routes/api/avis/[orgId].get.ts
Normal file
13
server/routes/api/avis/[orgId].get.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
const orgId = getRouterParam(event, 'orgId')
|
||||||
|
const url = `${config.nocodbUrl}/api/v2/tables/${config.avisTableId}/records?where=(organisation_id,eq,${orgId})~and(status,eq,approved)`
|
||||||
|
|
||||||
|
const data = await $fetch(url, {
|
||||||
|
headers: {
|
||||||
|
'xc-token': config.nocodbToken,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return data
|
||||||
|
})
|
||||||
50
server/routes/api/organisations.get.ts
Normal file
50
server/routes/api/organisations.get.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { readFileSync } from 'fs'
|
||||||
|
import { resolve } from 'path'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
|
||||||
|
// Paramètre optionnel ?format=map pour retourner uniquement les champs carte
|
||||||
|
const query = getQuery(event)
|
||||||
|
|
||||||
|
try {
|
||||||
|
// NocoDB V2 — table organisations, filtre approved + published
|
||||||
|
const url = `${config.nocodbUrl}/api/v2/tables/${config.orgTableId}/records?where=(moderation_status,eq,approved)&limit=300&sort=-Id`
|
||||||
|
|
||||||
|
const data: any = await $fetch(url, {
|
||||||
|
headers: { 'xc-token': config.nocodbToken },
|
||||||
|
timeout: 8000,
|
||||||
|
})
|
||||||
|
|
||||||
|
return { list: data?.list ?? [], source: 'nocodb' }
|
||||||
|
} catch (err) {
|
||||||
|
// Fallback seed JSON pour dev local si NocoDB inaccessible
|
||||||
|
console.warn('[NAV API] NocoDB inaccessible, fallback seed JSON:', err)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const seedPath = resolve(process.cwd(), 'V2-cadrage/seed-94-fiches-v2.json')
|
||||||
|
const raw = readFileSync(seedPath, 'utf-8')
|
||||||
|
const seed: any[] = JSON.parse(raw)
|
||||||
|
|
||||||
|
// Normaliser le seed au format NocoDB (ajouter Id fictif)
|
||||||
|
const list = seed.map((item: any, i: number) => ({
|
||||||
|
Id: 1000 + i,
|
||||||
|
nom: item.nom,
|
||||||
|
url: item.url,
|
||||||
|
description: item.description,
|
||||||
|
echelle: item.echelle,
|
||||||
|
tags_fonction: item.fonctions?.join(',') ?? '',
|
||||||
|
territoire: item.territoire,
|
||||||
|
localisation_ville: item.ville,
|
||||||
|
latitude: item.lat,
|
||||||
|
longitude: item.lon,
|
||||||
|
moderation_status: 'approved',
|
||||||
|
prioritaire: false,
|
||||||
|
}))
|
||||||
|
|
||||||
|
return { list, source: 'seed' }
|
||||||
|
} catch (seedErr) {
|
||||||
|
throw createError({ statusCode: 503, message: 'Service indisponible — NocoDB et seed inaccessibles' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
16
server/routes/api/organisations.post.ts
Normal file
16
server/routes/api/organisations.post.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
const body = await readBody(event)
|
||||||
|
const url = `${config.nocodbUrl}/api/v2/tables/${config.orgTableId}/records`
|
||||||
|
|
||||||
|
const data = await $fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'xc-token': config.nocodbToken,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
})
|
||||||
|
|
||||||
|
return data
|
||||||
|
})
|
||||||
17
server/routes/api/organisations/[id].get.ts
Normal file
17
server/routes/api/organisations/[id].get.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
const id = getRouterParam(event, 'id')
|
||||||
|
const url = `${config.nocodbUrl}/api/v2/tables/${config.orgTableId}/records?where=(Id,eq,${id})&limit=1`
|
||||||
|
|
||||||
|
const data: any = await $fetch(url, {
|
||||||
|
headers: {
|
||||||
|
'xc-token': config.nocodbToken,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const record = data?.list?.[0] ?? null
|
||||||
|
if (!record) {
|
||||||
|
throw createError({ statusCode: 404, message: 'Organisation non trouvée' })
|
||||||
|
}
|
||||||
|
return record
|
||||||
|
})
|
||||||
87
server/utils/circuitBreaker.ts
Normal file
87
server/utils/circuitBreaker.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
/**
|
||||||
|
* Circuit breaker budget IA
|
||||||
|
* Spec F §6 — seuil 20€/mois
|
||||||
|
*
|
||||||
|
* Avant chaque appel IA (worker ou chatbot) :
|
||||||
|
* const { blocked } = await checkBudget(config)
|
||||||
|
* if (blocked) throw createError({ statusCode: 503, ... })
|
||||||
|
*
|
||||||
|
* Paliers :
|
||||||
|
* >= 15€ → email Jules (géré par le worker)
|
||||||
|
* >= 18€ → flag budget_warning (bandeau site)
|
||||||
|
* >= 20€ → hard stop, HTTP 503
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const BUDGET_MAX_EUR = 20
|
||||||
|
export const BUDGET_WARN_EUR = 18
|
||||||
|
|
||||||
|
export interface BudgetStatus {
|
||||||
|
cumulEur: number
|
||||||
|
blocked: boolean
|
||||||
|
warning: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calcule le cumul de dépenses IA du mois courant depuis stats_usage NocoDB.
|
||||||
|
* Retourne blocked=true si le budget est atteint.
|
||||||
|
*/
|
||||||
|
export async function checkBudget(config: {
|
||||||
|
nocodbUrl: string
|
||||||
|
nocodbToken: string
|
||||||
|
statsTableId: string
|
||||||
|
}): Promise<BudgetStatus> {
|
||||||
|
const { nocodbUrl, nocodbToken, statsTableId } = config
|
||||||
|
|
||||||
|
// Premier du mois courant à minuit UTC
|
||||||
|
const now = new Date()
|
||||||
|
const monthStart = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1))
|
||||||
|
const monthStartIso = monthStart.toISOString()
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Fetch toutes les entrées du mois courant (NocoDB v2)
|
||||||
|
const url = `${nocodbUrl}/api/v2/tables/${statsTableId}/records`
|
||||||
|
|
||||||
|
const res = await $fetch<{ list: { cout_eur: number | null; timestamp: string }[] }>(
|
||||||
|
url,
|
||||||
|
{
|
||||||
|
headers: { 'xc-token': nocodbToken },
|
||||||
|
query: {
|
||||||
|
where: `(timestamp,gte,${monthStartIso})`,
|
||||||
|
limit: 1000,
|
||||||
|
fields: 'cout_eur,timestamp',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const rows = res?.list ?? []
|
||||||
|
const cumulEur = rows.reduce((sum, row) => sum + (Number(row.cout_eur) || 0), 0)
|
||||||
|
|
||||||
|
return {
|
||||||
|
cumulEur,
|
||||||
|
blocked: cumulEur >= BUDGET_MAX_EUR,
|
||||||
|
warning: cumulEur >= BUDGET_WARN_EUR,
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// En cas d'erreur de lecture, on ne bloque PAS pour ne pas pénaliser les utilisateurs
|
||||||
|
console.warn('[circuitBreaker] Erreur lecture stats_usage — budget non vérifié:', (e as Error).message)
|
||||||
|
return { cumulEur: 0, blocked: false, warning: false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calcule le coût en EUR d'un appel Mistral Small.
|
||||||
|
* Prix : $0.20/M tokens_in, $0.60/M tokens_out (converti en EUR @0.93)
|
||||||
|
*/
|
||||||
|
export function calcCoutMistralSmall(tokensIn: number, tokensOut: number): number {
|
||||||
|
const usd = (tokensIn / 1_000_000) * 0.2 + (tokensOut / 1_000_000) * 0.6
|
||||||
|
return usd * 0.93
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calcule le coût en EUR d'un appel Mistral Nemo.
|
||||||
|
* Prix : $0.02/M tokens_in, $0.04/M tokens_out (converti en EUR @0.93)
|
||||||
|
*/
|
||||||
|
export function calcCoutMistralNemo(tokensIn: number, tokensOut: number): number {
|
||||||
|
const usd = (tokensIn / 1_000_000) * 0.02 + (tokensOut / 1_000_000) * 0.04
|
||||||
|
return usd * 0.93
|
||||||
|
}
|
||||||
109
server/utils/rateLimit.ts
Normal file
109
server/utils/rateLimit.ts
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
/**
|
||||||
|
* Rate limiting via Redis (ioredis)
|
||||||
|
* Fallback : compteur en mémoire si Redis indisponible (dev local sans Docker)
|
||||||
|
*
|
||||||
|
* Usage :
|
||||||
|
* const allowed = await checkRateLimit(ip, 'submit', 3)
|
||||||
|
* if (!allowed) throw createError({ statusCode: 429, ... })
|
||||||
|
*/
|
||||||
|
|
||||||
|
import Redis from 'ioredis'
|
||||||
|
|
||||||
|
let redisClient: Redis | null = null
|
||||||
|
let redisConnected = false
|
||||||
|
|
||||||
|
// Compteur en mémoire (fallback si Redis KO)
|
||||||
|
const memoryCounters: Map<string, { count: number; resetAt: number }> = new Map()
|
||||||
|
|
||||||
|
function getRedisClient(): Redis | null {
|
||||||
|
if (redisClient !== null) return redisConnected ? redisClient : null
|
||||||
|
|
||||||
|
try {
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
const url = config.redisUrl || 'redis://127.0.0.1:6379'
|
||||||
|
redisClient = new Redis(url, {
|
||||||
|
lazyConnect: true,
|
||||||
|
enableOfflineQueue: false,
|
||||||
|
maxRetriesPerRequest: 1,
|
||||||
|
connectTimeout: 2000,
|
||||||
|
})
|
||||||
|
|
||||||
|
redisClient.on('connect', () => {
|
||||||
|
redisConnected = true
|
||||||
|
console.log('[rateLimit] Redis connecté')
|
||||||
|
})
|
||||||
|
|
||||||
|
redisClient.on('error', (err) => {
|
||||||
|
redisConnected = false
|
||||||
|
console.warn('[rateLimit] Redis erreur — fallback mémoire activé:', err.message)
|
||||||
|
})
|
||||||
|
|
||||||
|
redisClient.connect().catch(() => {
|
||||||
|
redisConnected = false
|
||||||
|
})
|
||||||
|
|
||||||
|
return redisConnected ? redisClient : null
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[rateLimit] Impossible de créer le client Redis — fallback mémoire')
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vérifie et incrémente le compteur d'appels.
|
||||||
|
* @param ip Adresse IP du client
|
||||||
|
* @param action Clé d'action (ex : 'submit', 'comment')
|
||||||
|
* @param maxPerDay Nombre max d'appels autorisés par jour
|
||||||
|
* @returns true si autorisé, false si limite dépassée
|
||||||
|
*/
|
||||||
|
export async function checkRateLimit(
|
||||||
|
ip: string,
|
||||||
|
action: string,
|
||||||
|
maxPerDay: number,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const key = `ratelimit:${action}:${ip}:${todayKey()}`
|
||||||
|
|
||||||
|
const client = getRedisClient()
|
||||||
|
|
||||||
|
if (client && redisConnected) {
|
||||||
|
try {
|
||||||
|
const count = await client.incr(key)
|
||||||
|
if (count === 1) {
|
||||||
|
// Expire à minuit (secondes restantes dans la journée)
|
||||||
|
await client.expireat(key, tomorrowMidnightUnix())
|
||||||
|
}
|
||||||
|
return count <= maxPerDay
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[rateLimit] Redis incr échoué — fallback mémoire:', (e as Error).message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback mémoire
|
||||||
|
return memoryRateLimit(key, maxPerDay)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function todayKey(): string {
|
||||||
|
const d = new Date()
|
||||||
|
return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}-${String(d.getUTCDate()).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function tomorrowMidnightUnix(): number {
|
||||||
|
const d = new Date()
|
||||||
|
d.setUTCHours(24, 0, 0, 0)
|
||||||
|
return Math.floor(d.getTime() / 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
function memoryRateLimit(key: string, maxPerDay: number): boolean {
|
||||||
|
const now = Date.now()
|
||||||
|
const entry = memoryCounters.get(key)
|
||||||
|
|
||||||
|
if (!entry || entry.resetAt <= now) {
|
||||||
|
memoryCounters.set(key, { count: 1, resetAt: tomorrowMidnightUnix() * 1000 })
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.count++
|
||||||
|
return entry.count <= maxPerDay
|
||||||
|
}
|
||||||
87
server/utils/rateLimitJson.ts
Normal file
87
server/utils/rateLimitJson.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
/**
|
||||||
|
* Rate limiting via fichiers JSON locaux
|
||||||
|
* Implémentation recommandée spec F §8 : /tmp/nav-ratelimit/{IP_hash}.json
|
||||||
|
*
|
||||||
|
* - IP hashée SHA-256 (RGPD — pas de stockage IP en clair)
|
||||||
|
* - Fichier JSON par IP, reset automatique si date != today
|
||||||
|
* - Dossier créé au premier appel si absent
|
||||||
|
*
|
||||||
|
* Usage :
|
||||||
|
* const allowed = await checkRateLimitJson(ip, 'chatbot', 10)
|
||||||
|
* if (!allowed) throw createError({ statusCode: 429, ... })
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createHash } from 'crypto'
|
||||||
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
|
||||||
|
import { join } from 'path'
|
||||||
|
|
||||||
|
const RATELIMIT_DIR = '/tmp/nav-ratelimit'
|
||||||
|
|
||||||
|
type RateLimitFile = {
|
||||||
|
[action: string]: { count: number; date: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureDir() {
|
||||||
|
if (!existsSync(RATELIMIT_DIR)) {
|
||||||
|
mkdirSync(RATELIMIT_DIR, { recursive: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hashIp(ip: string): string {
|
||||||
|
return createHash('sha256').update(ip).digest('hex')
|
||||||
|
}
|
||||||
|
|
||||||
|
function todayStr(): string {
|
||||||
|
const d = new Date()
|
||||||
|
return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}-${String(d.getUTCDate()).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function readFile(ipHash: string): RateLimitFile {
|
||||||
|
const path = join(RATELIMIT_DIR, `${ipHash}.json`)
|
||||||
|
try {
|
||||||
|
return JSON.parse(readFileSync(path, 'utf-8'))
|
||||||
|
} catch {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeFile(ipHash: string, data: RateLimitFile) {
|
||||||
|
const path = join(RATELIMIT_DIR, `${ipHash}.json`)
|
||||||
|
writeFileSync(path, JSON.stringify(data), 'utf-8')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vérifie et incrémente le compteur pour une IP et une action.
|
||||||
|
* @param ip Adresse IP du client (sera hashée SHA-256)
|
||||||
|
* @param action Clé d'action (ex : 'chatbot', 'submit', 'comment')
|
||||||
|
* @param maxPerDay Nombre max d'appels autorisés par jour
|
||||||
|
* @returns true si autorisé, false si limite dépassée
|
||||||
|
*/
|
||||||
|
export function checkRateLimitJson(
|
||||||
|
ip: string,
|
||||||
|
action: string,
|
||||||
|
maxPerDay: number,
|
||||||
|
): boolean {
|
||||||
|
ensureDir()
|
||||||
|
|
||||||
|
const ipHash = hashIp(ip)
|
||||||
|
const today = todayStr()
|
||||||
|
const data = readFile(ipHash)
|
||||||
|
|
||||||
|
const entry = data[action]
|
||||||
|
|
||||||
|
if (!entry || entry.date !== today) {
|
||||||
|
// Nouveau jour ou premier appel : reset et autoriser
|
||||||
|
data[action] = { count: 1, date: today }
|
||||||
|
writeFile(ipHash, data)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.count >= maxPerDay) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.count++
|
||||||
|
writeFile(ipHash, data)
|
||||||
|
return true
|
||||||
|
}
|
||||||
54
tailwind.config.js
Normal file
54
tailwind.config.js
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: [
|
||||||
|
'./components/**/*.{js,vue,ts}',
|
||||||
|
'./layouts/**/*.vue',
|
||||||
|
'./pages/**/*.vue',
|
||||||
|
'./plugins/**/*.{js,ts}',
|
||||||
|
'./app.vue',
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
/* Palette NAV V2 — Sobre institutionnel */
|
||||||
|
nav: {
|
||||||
|
bg: '#f8f6f1',
|
||||||
|
'bg-alt':'#eee9df',
|
||||||
|
surface: '#ffffff',
|
||||||
|
primary: 'rgba(26, 34, 56, 0.6)',
|
||||||
|
'primary-solid': '#1a2238',
|
||||||
|
accent: '#f5b342',
|
||||||
|
text: '#1a2238',
|
||||||
|
'text-muted': 'rgba(26, 34, 56, 0.55)',
|
||||||
|
'text-on-primary': '#f8f6f1',
|
||||||
|
},
|
||||||
|
/* Héritage V1 — gardé pour composants existants */
|
||||||
|
sage: {
|
||||||
|
50: '#f4f7f4',
|
||||||
|
100: '#e6ede6',
|
||||||
|
200: '#cddccd',
|
||||||
|
300: '#a9c2a9',
|
||||||
|
400: '#7ea07e',
|
||||||
|
500: '#5c7f5c',
|
||||||
|
600: '#476447',
|
||||||
|
700: '#3a503a',
|
||||||
|
800: '#304030',
|
||||||
|
900: '#293529',
|
||||||
|
},
|
||||||
|
warm: {
|
||||||
|
50: '#faf8f5',
|
||||||
|
100: '#f5f0e8',
|
||||||
|
200: '#ece0cf',
|
||||||
|
300: '#dfc9ad',
|
||||||
|
400: '#ceaa84',
|
||||||
|
500: '#c09060',
|
||||||
|
600: '#b07a4a',
|
||||||
|
700: '#93633c',
|
||||||
|
800: '#785134',
|
||||||
|
900: '#62432c',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}
|
||||||
38
types/org.ts
Normal file
38
types/org.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
/**
|
||||||
|
* Interface canonique Org — NAV V2
|
||||||
|
* Source unique : types/org.ts
|
||||||
|
* Importée dans pages/index.vue, pages/fiche/[id].vue, composants.
|
||||||
|
*/
|
||||||
|
export interface Org {
|
||||||
|
Id: number
|
||||||
|
nom: string
|
||||||
|
// Champs V1 (legacy — conservés pour compatibilité OrgCard)
|
||||||
|
type_org?: string
|
||||||
|
description?: string
|
||||||
|
tags?: string
|
||||||
|
lien?: string
|
||||||
|
// Champs V2
|
||||||
|
echelle?: string
|
||||||
|
tags_fonction?: string
|
||||||
|
territoire?: string
|
||||||
|
localisation_ville?: string
|
||||||
|
latitude?: number | null
|
||||||
|
longitude?: number | null
|
||||||
|
url?: string
|
||||||
|
prioritaire?: boolean
|
||||||
|
moderation_status?: string
|
||||||
|
// Contenu enrichi IA
|
||||||
|
description_user?: string
|
||||||
|
description_enrichie?: string
|
||||||
|
points_cles?: string | null
|
||||||
|
// Statuts traitement
|
||||||
|
ai_processed?: boolean
|
||||||
|
scrape_status?: string
|
||||||
|
submitted_by_email?: string
|
||||||
|
submitted_at?: string
|
||||||
|
moderated_at?: string
|
||||||
|
moderator_note?: string
|
||||||
|
// Hiérarchie (V2.5)
|
||||||
|
parent_id?: number | null
|
||||||
|
is_root?: boolean | null
|
||||||
|
}
|
||||||
44
vps-setup.sh
Normal file
44
vps-setup.sh
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Run this once on the VPS to set up the service
|
||||||
|
# ssh -p 4422 vps-hetzner "bash -s" < vps-setup.sh
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
APP_DIR="/opt/nav-carte"
|
||||||
|
PORT=3333
|
||||||
|
|
||||||
|
# Create app directory
|
||||||
|
mkdir -p "$APP_DIR"
|
||||||
|
|
||||||
|
# Create systemd service
|
||||||
|
cat > /etc/systemd/system/nav-carte.service << EOF
|
||||||
|
[Unit]
|
||||||
|
Description=NAV Carte des institutions (Nuxt 3)
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=root
|
||||||
|
WorkingDirectory=$APP_DIR
|
||||||
|
ExecStart=/usr/bin/node $APP_DIR/server/index.mjs
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
Environment=PORT=$PORT
|
||||||
|
Environment=HOST=0.0.0.0
|
||||||
|
EnvironmentFile=$APP_DIR/.env
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
EOF
|
||||||
|
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable nav-carte
|
||||||
|
echo "Service created. Run 'systemctl start nav-carte' after deploy."
|
||||||
|
echo ""
|
||||||
|
echo "Add this to /etc/caddy/Caddyfile:"
|
||||||
|
echo ""
|
||||||
|
echo "nav.trans-former.fr {"
|
||||||
|
echo " reverse_proxy localhost:$PORT"
|
||||||
|
echo "}"
|
||||||
|
echo ""
|
||||||
|
echo "Then: systemctl reload caddy"
|
||||||
234
worker/daily-digest.js
Normal file
234
worker/daily-digest.js
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// worker/daily-digest.js — Email récap quotidien nouvelles fiches AEP
|
||||||
|
// Cron : 0 8 * * * (tous les jours à 8h)
|
||||||
|
// Usage : node worker/daily-digest.js
|
||||||
|
|
||||||
|
import 'dotenv/config'
|
||||||
|
|
||||||
|
// ─── CONFIG DEPUIS .env ───────────────────────────────────────────────────────
|
||||||
|
const NOCODB_URL = process.env.NOCODB_URL || 'http://localhost:8070';
|
||||||
|
const NOCODB_TOKEN = process.env.NOCODB_TOKEN;
|
||||||
|
const NOCODB_BASE = process.env.NOCODB_BASE;
|
||||||
|
const NOCODB_TABLE_ORGAS = process.env.NOCODB_TABLE_ORGAS;
|
||||||
|
const RESEND_API_KEY = process.env.RESEND_API_KEY;
|
||||||
|
const RESEND_FROM = process.env.RESEND_FROM || 'AEP Digest <noreply@trans-former.fr>';
|
||||||
|
const EMAIL_DEST = process.env.EMAIL_JULES || 'transformationsresilientes@gmail.com';
|
||||||
|
const NOCODB_ADMIN_URL = process.env.NOCODB_ADMIN_URL || 'http://localhost:8070';
|
||||||
|
|
||||||
|
// ─── UTILITAIRES LOG ─────────────────────────────────────────────────────────
|
||||||
|
function log(...args) {
|
||||||
|
const ts = new Date().toISOString();
|
||||||
|
console.log(`[${ts}]`, ...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── NOCODB — FETCH FICHES DERNIÈRES 24H ─────────────────────────────────────
|
||||||
|
async function fetchNewFiches() {
|
||||||
|
const since = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
|
||||||
|
|
||||||
|
// NocoDB ne supporte pas bien le filtre datetime sur CreatedAt — on récupère
|
||||||
|
// les 200 dernières fiches triées par date décroissante et on filtre en JS
|
||||||
|
// (même pattern que getBudgetMoisCourant dans enrich.js)
|
||||||
|
const url = `${NOCODB_URL}/api/v1/db/data/noco/${NOCODB_BASE}/${NOCODB_TABLE_ORGAS}?limit=200&sort=-CreatedAt`;
|
||||||
|
|
||||||
|
const res = await fetch(url, {
|
||||||
|
headers: { 'xc-token': NOCODB_TOKEN }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`NocoDB GET fiches → ${res.status}: ${await res.text()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
const rows = data.list || [];
|
||||||
|
|
||||||
|
// Filtre JS sur les 24 dernières heures
|
||||||
|
const recent = rows.filter(row => {
|
||||||
|
const created = new Date(row.CreatedAt || row.created_at || 0);
|
||||||
|
return created >= new Date(since);
|
||||||
|
});
|
||||||
|
|
||||||
|
return recent;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── CONSTRUCTION EMAIL HTML ─────────────────────────────────────────────────
|
||||||
|
function buildEmailHtml(fiches) {
|
||||||
|
const count = fiches.length;
|
||||||
|
const date = new Date().toLocaleDateString('fr-FR', {
|
||||||
|
weekday: 'long', day: 'numeric', month: 'long', year: 'numeric'
|
||||||
|
});
|
||||||
|
|
||||||
|
const lignes = fiches.map(f => {
|
||||||
|
const nom = f.nom || '(sans nom)';
|
||||||
|
const url = f.url || null;
|
||||||
|
const echelle = f.echelle || '—';
|
||||||
|
const statut = f.moderation_status || 'pending';
|
||||||
|
const ficheUrl = `https://aep.trans-former.fr/fiche/${f.Id}`;
|
||||||
|
|
||||||
|
const nomHtml = url
|
||||||
|
? `<a href="${url}" style="color:#1a56db;text-decoration:none;">${escHtml(nom)}</a>`
|
||||||
|
: escHtml(nom);
|
||||||
|
|
||||||
|
const statutColor = statut === 'approved' ? '#057a55'
|
||||||
|
: statut === 'rejected' ? '#c81e1e'
|
||||||
|
: statut === 'ai_processed' ? '#1a56db'
|
||||||
|
: '#9ca3af'; // pending / autre
|
||||||
|
|
||||||
|
return `
|
||||||
|
<tr style="border-bottom:1px solid #f3f4f6;">
|
||||||
|
<td style="padding:10px 12px;font-size:14px;">${nomHtml}</td>
|
||||||
|
<td style="padding:10px 12px;font-size:13px;color:#6b7280;">${escHtml(echelle)}</td>
|
||||||
|
<td style="padding:10px 12px;">
|
||||||
|
<span style="display:inline-block;padding:2px 8px;border-radius:4px;font-size:12px;
|
||||||
|
font-weight:600;background:#f3f4f6;color:${statutColor};">
|
||||||
|
${escHtml(statut)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td style="padding:10px 12px;font-size:12px;">
|
||||||
|
<a href="${ficheUrl}" style="color:#6b7280;">fiche #${f.Id}</a>
|
||||||
|
</td>
|
||||||
|
</tr>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head>
|
||||||
|
<body style="margin:0;padding:0;background:#f9fafb;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;">
|
||||||
|
<table width="100%" cellpadding="0" cellspacing="0" style="background:#f9fafb;padding:32px 0;">
|
||||||
|
<tr><td align="center">
|
||||||
|
<table width="600" cellpadding="0" cellspacing="0" style="background:#ffffff;border-radius:8px;overflow:hidden;box-shadow:0 1px 3px rgba(0,0,0,0.1);">
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<tr>
|
||||||
|
<td style="background:#1a56db;padding:24px 32px;">
|
||||||
|
<p style="margin:0;font-size:12px;color:#bfdbfe;text-transform:uppercase;letter-spacing:1px;">AEP — Agences En Pratique</p>
|
||||||
|
<h1 style="margin:4px 0 0;font-size:20px;color:#ffffff;font-weight:700;">
|
||||||
|
${count} nouvelle${count > 1 ? 's' : ''} fiche${count > 1 ? 's' : ''} soumise${count > 1 ? 's' : ''}
|
||||||
|
</h1>
|
||||||
|
<p style="margin:4px 0 0;font-size:13px;color:#bfdbfe;">${date}</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Corps -->
|
||||||
|
<tr>
|
||||||
|
<td style="padding:24px 32px;">
|
||||||
|
<p style="margin:0 0 16px;font-size:14px;color:#374151;">
|
||||||
|
${count} fiche${count > 1 ? 's ont été soumises' : ' a été soumise'} dans les dernières 24 heures.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Tableau fiches -->
|
||||||
|
<table width="100%" cellpadding="0" cellspacing="0" style="border-collapse:collapse;border:1px solid #e5e7eb;border-radius:6px;overflow:hidden;">
|
||||||
|
<thead>
|
||||||
|
<tr style="background:#f9fafb;">
|
||||||
|
<th style="padding:10px 12px;text-align:left;font-size:12px;font-weight:600;color:#6b7280;text-transform:uppercase;letter-spacing:0.5px;">Organisation</th>
|
||||||
|
<th style="padding:10px 12px;text-align:left;font-size:12px;font-weight:600;color:#6b7280;text-transform:uppercase;letter-spacing:0.5px;">Échelle</th>
|
||||||
|
<th style="padding:10px 12px;text-align:left;font-size:12px;font-weight:600;color:#6b7280;text-transform:uppercase;letter-spacing:0.5px;">Statut</th>
|
||||||
|
<th style="padding:10px 12px;text-align:left;font-size:12px;font-weight:600;color:#6b7280;text-transform:uppercase;letter-spacing:0.5px;">Lien</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
${lignes}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- CTA modération -->
|
||||||
|
<div style="margin-top:24px;text-align:center;">
|
||||||
|
<a href="${NOCODB_ADMIN_URL}"
|
||||||
|
style="display:inline-block;padding:10px 20px;background:#1a56db;color:#ffffff;
|
||||||
|
border-radius:6px;font-size:14px;font-weight:600;text-decoration:none;">
|
||||||
|
Ouvrir NocoDB pour modérer
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<tr>
|
||||||
|
<td style="padding:16px 32px;border-top:1px solid #f3f4f6;">
|
||||||
|
<p style="margin:0;font-size:11px;color:#9ca3af;text-align:center;">
|
||||||
|
Digest automatique AEP — envoyé chaque matin à 8h
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
</table>
|
||||||
|
</td></tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Échappement HTML minimal
|
||||||
|
function escHtml(str) {
|
||||||
|
return String(str ?? '')
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── ENVOI VIA RESEND ─────────────────────────────────────────────────────────
|
||||||
|
async function sendDigest(fiches) {
|
||||||
|
const count = fiches.length;
|
||||||
|
const subject = `AEP — ${count} nouvelle${count > 1 ? 's' : ''} fiche${count > 1 ? 's' : ''} soumise${count > 1 ? 's' : ''}`;
|
||||||
|
const html = buildEmailHtml(fiches);
|
||||||
|
|
||||||
|
const res = await fetch('https://api.resend.com/emails', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${RESEND_API_KEY}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
from: RESEND_FROM,
|
||||||
|
to: [EMAIL_DEST],
|
||||||
|
subject,
|
||||||
|
html
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.text();
|
||||||
|
throw new Error(`Resend API ${res.status}: ${err}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
log(`Email digest envoyé → ${EMAIL_DEST} | id: ${data.id} | sujet: "${subject}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── MAIN ─────────────────────────────────────────────────────────────────────
|
||||||
|
async function run() {
|
||||||
|
log('=== daily-digest démarré ===');
|
||||||
|
|
||||||
|
// Vérification variables obligatoires
|
||||||
|
if (!NOCODB_TOKEN || !NOCODB_BASE || !NOCODB_TABLE_ORGAS) {
|
||||||
|
log('ERREUR : Variables NocoDB manquantes (NOCODB_TOKEN, NOCODB_BASE, NOCODB_TABLE_ORGAS)');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!RESEND_API_KEY) {
|
||||||
|
log('ERREUR : RESEND_API_KEY manquante');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Fetch fiches des 24 dernières heures
|
||||||
|
const fiches = await fetchNewFiches();
|
||||||
|
log(`${fiches.length} fiche(s) trouvée(s) dans les dernières 24h`);
|
||||||
|
|
||||||
|
if (fiches.length === 0) {
|
||||||
|
log('Aucune nouvelle fiche — digest non envoyé.');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Envoi email
|
||||||
|
await sendDigest(fiches);
|
||||||
|
log('=== daily-digest terminé avec succès ===');
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
log('ERREUR daily-digest:', e.message);
|
||||||
|
console.error(e.stack);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
run();
|
||||||
495
worker/enrich.js
Normal file
495
worker/enrich.js
Normal file
@@ -0,0 +1,495 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* NAV V2 — Worker enrichissement IA
|
||||||
|
* Lancé via systemd timer toutes les 5 minutes
|
||||||
|
* Pipeline : fetch pending → scrape crawl4ai → Mistral Nemo → update NocoDB → log stats
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { spawnSync, execSync } from 'child_process';
|
||||||
|
import { existsSync, writeFileSync, unlinkSync } from 'fs';
|
||||||
|
import { tmpdir } from 'os';
|
||||||
|
import { join } from 'path';
|
||||||
|
|
||||||
|
// ─── CONFIG DEPUIS .env ───────────────────────────────────────────────────────
|
||||||
|
const NOCODB_URL = process.env.NOCODB_URL || 'http://localhost:8070';
|
||||||
|
const NOCODB_TOKEN = process.env.NOCODB_TOKEN;
|
||||||
|
const NOCODB_BASE = process.env.NOCODB_BASE;
|
||||||
|
const NOCODB_TABLE_ORGAS = process.env.NOCODB_TABLE_ORGAS;
|
||||||
|
const NOCODB_TABLE_STATS = process.env.NOCODB_TABLE_STATS;
|
||||||
|
const MISTRAL_API_KEY = process.env.MISTRAL_API_KEY;
|
||||||
|
const RESEND_API_KEY = process.env.RESEND_API_KEY;
|
||||||
|
const RESEND_FROM = process.env.RESEND_FROM || 'contact@trans-former.fr';
|
||||||
|
const EMAIL_JULES = process.env.EMAIL_JULES || 'jules@trans-former.fr';
|
||||||
|
const BUDGET_MAX_EUR = parseFloat(process.env.BUDGET_MAX_EUR || '20');
|
||||||
|
const WORKER_LIMIT = parseInt(process.env.WORKER_LIMIT || '5');
|
||||||
|
const LOCK_FILE = '/tmp/nav-worker.lock';
|
||||||
|
|
||||||
|
// ─── PRIX MISTRAL NEMO (USD → EUR) ───────────────────────────────────────────
|
||||||
|
const NEMO_PRICE_IN = 0.02 / 1_000_000; // $0.02 / 1M tokens input
|
||||||
|
const NEMO_PRICE_OUT = 0.04 / 1_000_000; // $0.04 / 1M tokens output
|
||||||
|
const USD_TO_EUR = 0.93;
|
||||||
|
|
||||||
|
// ─── TAXONOMIE VALIDE (apostrophe typographique U+2019 comme NocoDB) ─────────
|
||||||
|
const VALID_FONCTIONS = [
|
||||||
|
'Juridique', 'Technique', 'Économique', 'Administratif', 'Chantier',
|
||||||
|
'Comptabilité', 'Développement', 'Formation', 'Gestion d\u2019agence', 'Santé mentale'
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── MAPPING NORMALISATION TAGS ───────────────────────────────────────────────
|
||||||
|
const TAG_MAP = [
|
||||||
|
[['juridique', 'droit', 'litige', 'contrat', 'déontologie', 'décennale', 'médiation', 'pi ', 'propriété intellectuelle', 'ccag', 'marchés publics droit'], 'Juridique'],
|
||||||
|
[['technique', 're2020', 'thermique', 'structure', 'bim', 'dtu', 'acoustique', 'matériaux', 'simulation', 'opr', 'réserves', 'acv', 'pcd', 'eurocodes', 'pmc'], 'Technique'],
|
||||||
|
[['économique', 'prix', 'tarif', 'honoraire', 'devis', 'roi', 'financement', 'subvention', 'cee', 'maprimerenov', 'anah', 'business plan', 'pricing'], 'Économique'],
|
||||||
|
[['administratif', 'permis', 'plu', 'plui', 'erp', 'autorisation travaux', 'abf', 'patrimoine', 'marchés publics procédure', 'cctp', 'dpgf', 'concours', 'urbanisme'], 'Administratif'],
|
||||||
|
[['chantier', 'coordination chantier', 'det', 'suivi travaux', 'sps', 'sécurité chantier', 'planning chantier', 'entreprise', 'sous-traitance', 'réception travaux'], 'Chantier'],
|
||||||
|
[['comptabilité', 'fiscal', 'tva', 'bnc', 'bic', 'expert-comptable', 'bilan', 'trésorerie', 'transmission agence', 'création agence', 'micro'], 'Comptabilité'],
|
||||||
|
[['développement', 'prospection', 'commercial', 'client', 'réseau', 'candidature', 'consultation', 'acquisition', 'marketing', 'notoriété', 'ao '], 'Développement'],
|
||||||
|
[['formation', 'école', 'mooc', 'organisme', 'formation continue', 'cpf', 'dpc', 'cfaa'], 'Formation'],
|
||||||
|
[['gestion d\u2019agence', 'gestion d\'agence', 'rh', 'recrutement', 'emploi', 'salaire', 'ccn', 'convention collective', 'idcc', 'temps de travail', 'management'], 'Gestion d\u2019agence'],
|
||||||
|
[['santé mentale', 'burn-out', 'épuisement', 'souffrance', 'bien-être', 'harcèlement', 'stress', 'psychologique', 'équilibre'], 'Santé mentale'],
|
||||||
|
];
|
||||||
|
|
||||||
|
function normalizeTag(raw) {
|
||||||
|
const t = raw.toLowerCase().trim();
|
||||||
|
// D'abord chercher correspondance exacte dans les valeurs valides
|
||||||
|
const exact = VALID_FONCTIONS.find(v => v.toLowerCase() === t);
|
||||||
|
if (exact) return exact;
|
||||||
|
// Sinon chercher par mots-clés
|
||||||
|
for (const [patterns, normalized] of TAG_MAP) {
|
||||||
|
if (patterns.some(p => t.includes(p))) return normalized;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── UTILITAIRES LOG ─────────────────────────────────────────────────────────
|
||||||
|
function log(...args) {
|
||||||
|
const ts = new Date().toISOString();
|
||||||
|
console.log(`[${ts}]`, ...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── LOCK ANTI-OVERLAP ────────────────────────────────────────────────────────
|
||||||
|
function acquireLock() {
|
||||||
|
if (existsSync(LOCK_FILE)) {
|
||||||
|
const content = execSync(`cat ${LOCK_FILE}`).toString().trim();
|
||||||
|
const pid = parseInt(content);
|
||||||
|
try {
|
||||||
|
execSync(`kill -0 ${pid} 2>/dev/null`);
|
||||||
|
return false; // Process encore vivant
|
||||||
|
} catch {
|
||||||
|
log('Lock orphelin détecté, suppression');
|
||||||
|
unlinkSync(LOCK_FILE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeFileSync(LOCK_FILE, process.pid.toString());
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function releaseLock() {
|
||||||
|
try { unlinkSync(LOCK_FILE); } catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── NOCODB API ──────────────────────────────────────────────────────────────
|
||||||
|
async function nocodbGet(path) {
|
||||||
|
const res = await fetch(`${NOCODB_URL}/api/v1/db/data/noco/${NOCODB_BASE}/${path}`, {
|
||||||
|
headers: { 'xc-token': NOCODB_TOKEN }
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`NocoDB GET ${path} → ${res.status}: ${await res.text()}`);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function nocodbPatch(tableId, rowId, data) {
|
||||||
|
const res = await fetch(`${NOCODB_URL}/api/v1/db/data/noco/${NOCODB_BASE}/${tableId}/${rowId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'xc-token': NOCODB_TOKEN, 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`NocoDB PATCH ${tableId}/${rowId} → ${res.status}: ${await res.text()}`);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function nocodbPost(tableId, data) {
|
||||||
|
const res = await fetch(`${NOCODB_URL}/api/v1/db/data/noco/${NOCODB_BASE}/${tableId}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'xc-token': NOCODB_TOKEN, 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`NocoDB POST ${tableId} → ${res.status}: ${await res.text()}`);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── BUDGET CIRCUIT BREAKER ──────────────────────────────────────────────────
|
||||||
|
async function getBudgetMoisCourant() {
|
||||||
|
const now = new Date();
|
||||||
|
const year = now.getFullYear();
|
||||||
|
const month = now.getMonth(); // 0-indexed
|
||||||
|
// NocoDB ne supporte pas bien le filtre datetime — on récupère tout et filtre en JS
|
||||||
|
try {
|
||||||
|
const data = await nocodbGet(`${NOCODB_TABLE_STATS}?limit=1000&sort=-timestamp`);
|
||||||
|
const total = (data.list || []).reduce((sum, row) => {
|
||||||
|
const ts = new Date(row.timestamp || row.CreatedAt || 0);
|
||||||
|
if (ts.getFullYear() === year && ts.getMonth() === month) {
|
||||||
|
return sum + (parseFloat(row.cout_eur) || 0);
|
||||||
|
}
|
||||||
|
return sum;
|
||||||
|
}, 0);
|
||||||
|
return total;
|
||||||
|
} catch (e) {
|
||||||
|
log('Erreur lecture budget:', e.message);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function logUsage(usage, model, endpoint, orgaId) {
|
||||||
|
const tokensIn = usage?.prompt_tokens || 0;
|
||||||
|
const tokensOut = usage?.completion_tokens || 0;
|
||||||
|
const coutEur = ((tokensIn * NEMO_PRICE_IN) + (tokensOut * NEMO_PRICE_OUT)) * USD_TO_EUR;
|
||||||
|
|
||||||
|
await nocodbPost(NOCODB_TABLE_STATS, {
|
||||||
|
model,
|
||||||
|
endpoint,
|
||||||
|
tokens_in: tokensIn,
|
||||||
|
tokens_out: tokensOut,
|
||||||
|
cout_eur: parseFloat(coutEur.toFixed(6)),
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
orga_id: orgaId || null
|
||||||
|
});
|
||||||
|
|
||||||
|
log(`Usage log: ${tokensIn}in + ${tokensOut}out = €${coutEur.toFixed(6)} (${model})`);
|
||||||
|
return coutEur;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── FETCH FICHES PENDING ────────────────────────────────────────────────────
|
||||||
|
async function fetchPendingRows() {
|
||||||
|
const data = await nocodbGet(
|
||||||
|
`${NOCODB_TABLE_ORGAS}?where=(moderation_status,eq,pending)~and(ai_processed,eq,false)&limit=${WORKER_LIMIT}&sort=submitted_at`
|
||||||
|
);
|
||||||
|
return data.list || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── SCRAPING CRAWL4AI (mode HTTP statique, sans Playwright) ─────────────────
|
||||||
|
async function scrapeWithCrawl4ai(url) {
|
||||||
|
log(`Scraping: ${url}`);
|
||||||
|
|
||||||
|
// Script Python temporaire pour crawl4ai
|
||||||
|
const urlSafe = url.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
|
||||||
|
const script = `
|
||||||
|
import asyncio, sys
|
||||||
|
from crawl4ai import AsyncWebCrawler, CrawlerRunConfig
|
||||||
|
from crawl4ai.async_crawler_strategy import AsyncHTTPCrawlerStrategy
|
||||||
|
|
||||||
|
async def scrape():
|
||||||
|
strategy = AsyncHTTPCrawlerStrategy()
|
||||||
|
run_cfg = CrawlerRunConfig(
|
||||||
|
word_count_threshold=20,
|
||||||
|
excluded_tags=['nav', 'footer', 'script', 'style', 'head'],
|
||||||
|
remove_overlay_elements=True
|
||||||
|
)
|
||||||
|
async with AsyncWebCrawler(crawler_strategy=strategy, verbose=False) as crawler:
|
||||||
|
result = await crawler.arun(url='${urlSafe}', config=run_cfg)
|
||||||
|
if result.success and result.markdown:
|
||||||
|
content = result.markdown[:16000]
|
||||||
|
sys.stdout.buffer.write(content.encode('utf-8'))
|
||||||
|
else:
|
||||||
|
sys.stderr.write(f"Scrape failed: success={result.success}\\n")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
asyncio.run(scrape())
|
||||||
|
`;
|
||||||
|
|
||||||
|
const scriptPath = join(tmpdir(), `nav-scrape-${Date.now()}.py`);
|
||||||
|
writeFileSync(scriptPath, script, 'utf-8');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = spawnSync('python3', [scriptPath], {
|
||||||
|
timeout: 180_000,
|
||||||
|
maxBuffer: 20 * 1024 * 1024,
|
||||||
|
env: { ...process.env, PYTHONDONTWRITEBYTECODE: '1', PYTHONIOENCODING: 'utf-8' }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.error) throw result.error;
|
||||||
|
|
||||||
|
const stdout = result.stdout?.toString('utf-8') || '';
|
||||||
|
const stderr = result.stderr?.toString('utf-8') || '';
|
||||||
|
|
||||||
|
if (result.status === 0 && stdout.length > 50) {
|
||||||
|
log(`Scrape OK: ${stdout.length} chars`);
|
||||||
|
return stdout.trim();
|
||||||
|
} else {
|
||||||
|
throw new Error(`Scrape failed (code ${result.status}): ${stderr.slice(0, 300)}`);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
try { unlinkSync(scriptPath); } catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── APPEL MISTRAL NEMO ───────────────────────────────────────────────────────
|
||||||
|
const SYSTEM_PROMPT = `Tu es un assistant spécialisé dans l'écosystème professionnel de l'architecture en France. Tu reçois des informations sur une organisation ou ressource liée au secteur de l'architecture, et tu dois les enrichir pour alimenter une cartographie collaborative.
|
||||||
|
|
||||||
|
RÈGLES ABSOLUES :
|
||||||
|
1. Tu ne dois JAMAIS inventer d'informations non présentes dans les sources fournies.
|
||||||
|
2. Si une information est absente ou incertaine, retourne \`null\` pour ce champ.
|
||||||
|
3. Tu dois retourner UNIQUEMENT un objet JSON valide, sans texte avant ou après.
|
||||||
|
4. La description_enrichie doit être neutre, factuelle, en français, sans jugement de valeur.
|
||||||
|
5. Les points_cles sont des phrases courtes (max 12 mots chacune), actionnables pour un architecte.
|
||||||
|
6. Pour les tags_fonction, ne propose que des valeurs parmi la liste autorisée.
|
||||||
|
|
||||||
|
TAXONOMIE AUTORISÉE :
|
||||||
|
- Échelle (une seule valeur) : "National" | "Régional" | "Départemental" | "Local"
|
||||||
|
- Territoire (une seule valeur) : "Métropole" | "Guadeloupe" | "Martinique" | "Guyane" | "Réunion" | "Mayotte" | null
|
||||||
|
- Tags fonction (1 à 5 valeurs) : "Juridique" | "Technique" | "Économique" | "Administratif" | "Chantier" | "Comptabilité" | "Développement" | "Formation" | "Gestion d\u2019agence" | "Santé mentale"
|
||||||
|
|
||||||
|
FORMAT DE SORTIE JSON :
|
||||||
|
{
|
||||||
|
"description_enrichie": "string (max 300 chars, français, neutre, factuel)",
|
||||||
|
"points_cles": ["string", "string", "string"],
|
||||||
|
"tags_fonction": ["Valeur1", "Valeur2"],
|
||||||
|
"echelle": "National" | "Régional" | "Départemental" | "Local" | null,
|
||||||
|
"territoire": "Métropole" | ... | null,
|
||||||
|
"localisation_ville": "string" | null,
|
||||||
|
"confiance": "haute" | "moyenne" | "faible"
|
||||||
|
}
|
||||||
|
|
||||||
|
Le champ "confiance" reflète ta certitude globale sur l'enrichissement :
|
||||||
|
- "haute" : URL scrapée avec contenu riche, informations claires
|
||||||
|
- "moyenne" : URL scrapée mais contenu partiel, ou description_user seule suffisante
|
||||||
|
- "faible" : URL non disponible et description_user vague, inférences importantes`;
|
||||||
|
|
||||||
|
function buildUserPrompt(row, scrapeContent) {
|
||||||
|
return `ORGANISATION À ENRICHIR :
|
||||||
|
|
||||||
|
Nom : ${row.nom}
|
||||||
|
URL : ${row.url || 'non fournie'}
|
||||||
|
Description soumise par l'utilisateur : ${row.description_user || row.description || 'non fournie'}
|
||||||
|
|
||||||
|
CONTENU DU SITE WEB (extrait par scraping) :
|
||||||
|
${scrapeContent || 'Site non accessible ou URL non fournie.'}
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Enrichis cette fiche selon les règles du system prompt. Retourne uniquement le JSON.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function callMistralWithRetry(row, scrapeContent, maxRetries = 2) {
|
||||||
|
const userPrompt = buildUserPrompt(row, scrapeContent);
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||||
|
if (attempt > 0) {
|
||||||
|
log(`Retry ${attempt}/${maxRetries} pour fiche ${row.Id}`);
|
||||||
|
await new Promise(r => setTimeout(r, 2000 * attempt));
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('https://api.mistral.ai/v1/chat/completions', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${MISTRAL_API_KEY}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: 'open-mistral-nemo',
|
||||||
|
temperature: 0.2,
|
||||||
|
max_tokens: 800,
|
||||||
|
response_format: { type: 'json_object' },
|
||||||
|
messages: [
|
||||||
|
{ role: 'system', content: SYSTEM_PROMPT },
|
||||||
|
{ role: 'user', content: userPrompt }
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
signal: AbortSignal.timeout(60_000)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.text();
|
||||||
|
throw new Error(`Mistral API ${res.status}: ${err}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
const content = data.choices?.[0]?.message?.content;
|
||||||
|
if (!content) throw new Error('Réponse Mistral vide');
|
||||||
|
|
||||||
|
const parsed = JSON.parse(content);
|
||||||
|
// Attacher usage pour logging
|
||||||
|
parsed._usage = data.usage;
|
||||||
|
parsed._raw = content;
|
||||||
|
return parsed;
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
log(`Erreur Mistral (tentative ${attempt + 1}): ${e.message}`);
|
||||||
|
if (attempt === maxRetries) return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── EMAIL JULES VIA RESEND ───────────────────────────────────────────────────
|
||||||
|
async function sendEmailJules(subject, body) {
|
||||||
|
if (!RESEND_API_KEY) {
|
||||||
|
log('RESEND_API_KEY absent, email skippé');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await fetch('https://api.resend.com/emails', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${RESEND_API_KEY}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
from: RESEND_FROM,
|
||||||
|
to: [EMAIL_JULES],
|
||||||
|
subject,
|
||||||
|
text: body
|
||||||
|
})
|
||||||
|
});
|
||||||
|
if (!res.ok) log('Email error:', await res.text());
|
||||||
|
else log('Email envoyé à Jules:', subject);
|
||||||
|
} catch (e) {
|
||||||
|
log('Email exception:', e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── VÉRIFICATION SEUIL 5 FICHES PENDING MODÉRATION ─────────────────────────
|
||||||
|
async function checkModerationQueue() {
|
||||||
|
const data = await nocodbGet(
|
||||||
|
`${NOCODB_TABLE_ORGAS}?where=(moderation_status,eq,ai_processed)&limit=100`
|
||||||
|
);
|
||||||
|
const count = data.pageInfo?.totalRows || 0;
|
||||||
|
if (count >= 5) {
|
||||||
|
log(`Seuil modération atteint: ${count} fiches en attente`);
|
||||||
|
await sendEmailJules(
|
||||||
|
`NAV — ${count} fiches à modérer`,
|
||||||
|
`Bonjour Jules,\n\n${count} fiches ont été enrichies par l'IA et attendent ta validation dans NocoDB.\n\nLien NocoDB : http://localhost:8070\nFiltre : moderation_status = ai_processed\n\nBonne modération !`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── MAIN ─────────────────────────────────────────────────────────────────────
|
||||||
|
async function run() {
|
||||||
|
if (!acquireLock()) {
|
||||||
|
log('Worker déjà en cours, skip');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const startTime = Date.now();
|
||||||
|
log('=== Worker NAV enrichissement démarré ===');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Vérification clés obligatoires
|
||||||
|
if (!NOCODB_TOKEN || !NOCODB_BASE || !NOCODB_TABLE_ORGAS || !MISTRAL_API_KEY) {
|
||||||
|
throw new Error('Variables .env manquantes (NOCODB_TOKEN, NOCODB_BASE, NOCODB_TABLE_ORGAS, MISTRAL_API_KEY)');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check budget global
|
||||||
|
const budgetMois = await getBudgetMoisCourant();
|
||||||
|
log(`Budget mois courant: €${budgetMois.toFixed(4)} / €${BUDGET_MAX_EUR}`);
|
||||||
|
|
||||||
|
if (budgetMois >= BUDGET_MAX_EUR) {
|
||||||
|
log('Budget épuisé pour ce mois. Worker en pause.');
|
||||||
|
await sendEmailJules(
|
||||||
|
'NAV — Budget IA épuisé ce mois',
|
||||||
|
`Le budget IA de ${BUDGET_MAX_EUR}€ a été atteint. Le worker est en pause jusqu'au 1er du mois prochain.\n\nConsommation actuelle : €${budgetMois.toFixed(4)}`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch fiches pending
|
||||||
|
const rows = await fetchPendingRows();
|
||||||
|
log(`${rows.length} fiche(s) à traiter`);
|
||||||
|
|
||||||
|
if (rows.length === 0) {
|
||||||
|
log('Rien à traiter.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let processedCount = 0;
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
const rowStart = Date.now();
|
||||||
|
log(`--- Traitement fiche ${row.Id}: ${row.nom} ---`);
|
||||||
|
|
||||||
|
// Re-check budget avant chaque fiche
|
||||||
|
const budgetCheck = await getBudgetMoisCourant();
|
||||||
|
if (budgetCheck >= BUDGET_MAX_EUR) {
|
||||||
|
log('Budget atteint mid-pipeline, arrêt.');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scraping
|
||||||
|
let scrapeContent = null;
|
||||||
|
const hasUrl = row.url && row.url.trim().length > 0;
|
||||||
|
const shouldScrape = hasUrl && (row.scrape_status === 'pending' || !row.scrape_status);
|
||||||
|
|
||||||
|
if (shouldScrape) {
|
||||||
|
try {
|
||||||
|
scrapeContent = await scrapeWithCrawl4ai(row.url);
|
||||||
|
await nocodbPatch(NOCODB_TABLE_ORGAS, row.Id, {
|
||||||
|
scrape_status: 'scraped',
|
||||||
|
scrape_content: scrapeContent
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
log(`Scrape échoué: ${e.message}`);
|
||||||
|
await nocodbPatch(NOCODB_TABLE_ORGAS, row.Id, { scrape_status: 'failed' });
|
||||||
|
// Continue avec l'IA sans contenu scrape
|
||||||
|
}
|
||||||
|
} else if (!hasUrl) {
|
||||||
|
await nocodbPatch(NOCODB_TABLE_ORGAS, row.Id, { scrape_status: 'no_link' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Appel Mistral Nemo
|
||||||
|
const enriched = await callMistralWithRetry(row, scrapeContent);
|
||||||
|
|
||||||
|
if (!enriched) {
|
||||||
|
log(`Échec Mistral sur fiche ${row.Id}, flag ai_error`);
|
||||||
|
await nocodbPatch(NOCODB_TABLE_ORGAS, row.Id, {
|
||||||
|
moderation_status: 'ai_error',
|
||||||
|
ai_processed: true
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalisation tags
|
||||||
|
const rawTags = enriched.tags_fonction || [];
|
||||||
|
const normalizedTags = [...new Set(rawTags.map(normalizeTag).filter(Boolean))];
|
||||||
|
|
||||||
|
// Update NocoDB
|
||||||
|
const updateData = {
|
||||||
|
description_enrichie: enriched.description_enrichie || null,
|
||||||
|
points_cles: enriched.points_cles ? JSON.stringify(enriched.points_cles) : null,
|
||||||
|
tags_fonction: normalizedTags.join(','),
|
||||||
|
moderation_status: 'ai_processed',
|
||||||
|
ai_processed: true,
|
||||||
|
ai_raw_output: JSON.stringify({ output: enriched, confiance: enriched.confiance })
|
||||||
|
};
|
||||||
|
|
||||||
|
// Conserver echelle/territoire/localisation si l'IA les a enrichis
|
||||||
|
if (enriched.echelle && !row.echelle) updateData.echelle = enriched.echelle;
|
||||||
|
if (enriched.territoire && !row.territoire) updateData.territoire = enriched.territoire;
|
||||||
|
if (enriched.localisation_ville && !row.localisation_ville) {
|
||||||
|
updateData.localisation_ville = enriched.localisation_ville;
|
||||||
|
}
|
||||||
|
|
||||||
|
await nocodbPatch(NOCODB_TABLE_ORGAS, row.Id, updateData);
|
||||||
|
|
||||||
|
// Log usage tokens
|
||||||
|
await logUsage(enriched._usage, 'open-mistral-nemo', 'enrichissement', row.Id);
|
||||||
|
|
||||||
|
const elapsed = ((Date.now() - rowStart) / 1000).toFixed(1);
|
||||||
|
log(`Fiche ${row.Id} traitée en ${elapsed}s — confiance: ${enriched.confiance || 'nc'}`);
|
||||||
|
processedCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
log(`=== Run terminé: ${processedCount}/${rows.length} fiches traitées en ${((Date.now() - startTime) / 1000).toFixed(1)}s ===`);
|
||||||
|
|
||||||
|
// Check seuil modération
|
||||||
|
await checkModerationQueue();
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
log('ERREUR WORKER:', e.message);
|
||||||
|
console.error(e.stack);
|
||||||
|
process.exit(1);
|
||||||
|
} finally {
|
||||||
|
releaseLock();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
run();
|
||||||
13
worker/package.json
Normal file
13
worker/package.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"name": "nav-worker",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"description": "NAV V2 — Worker enrichissement IA (Mistral Nemo + crawl4ai)",
|
||||||
|
"main": "enrich.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node -r dotenv/config enrich.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"dotenv": "^16.4.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user