wip: snapshot V2 cascade onglet 2 (sauvegarde avant chirurgie git-hygiene)
This commit is contained in:
@@ -8,27 +8,24 @@
|
||||
</NuxtLink>
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════════
|
||||
SECTION 1 - Mission AEP
|
||||
SECTION 1 — Mission AEP
|
||||
══════════════════════════════════════════════════════════ -->
|
||||
<!-- TODO Jules : Écrire le pitch (~100 mots) - qui est AEP, pour qui, pourquoi, quelle promesse -->
|
||||
<!-- 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.
|
||||
[Placeholder — Jules réécrira ce texte. Pitch ~100 mots : la carte, pourquoi elle existe, pour qui, ce qu'elle change pour les architectes en transition.]
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════════
|
||||
SECTION 2 - Souveraineté technique
|
||||
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>
|
||||
<h2>Souveraineté technique</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.
|
||||
[Placeholder — une phrase sur le choix délibéré d'une stack souveraine.]
|
||||
</p>
|
||||
<div class="badges-row">
|
||||
<div class="badge">
|
||||
@@ -37,7 +34,7 @@
|
||||
<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>
|
||||
<span class="badge-detail">Mistral AI, Paris</span>
|
||||
</div>
|
||||
<div class="badge">
|
||||
<span class="badge-icon" aria-hidden="true">
|
||||
@@ -51,38 +48,47 @@
|
||||
<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>
|
||||
<span class="badge-detail">Pas de Google Analytics, pas de Meta Pixel, pas de tracker tiers</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════════
|
||||
SECTION 3 - Gouvernance
|
||||
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>
|
||||
[Placeholder — Projet porté par [nom], collectif informel, [...]. Décisions prises en transparence. Financement participatif via <a href="https://liberapay.com/trans-former.fr" target="_blank" rel="noopener noreferrer" class="text-link">Liberapay</a>.]
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════════
|
||||
SECTION 4 - Transparence Liberapay
|
||||
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.
|
||||
[Placeholder — Ce que le projet coûte, pourquoi un financement participatif, ce que ça permettrait de faire.]
|
||||
</p>
|
||||
<p class="liberapay-note">
|
||||
Liberapay est une plateforme open source EU (ASBL belge non-lucrative), 0% de frais.
|
||||
</p>
|
||||
|
||||
<!-- Widget Liberapay -->
|
||||
<div class="liberapay-widget-wrap">
|
||||
<iframe
|
||||
src="https://liberapay.com/trans-former.fr/widgets/receiving.html"
|
||||
width="100%"
|
||||
height="60"
|
||||
style="border: 0;"
|
||||
title="Dons reçus via Liberapay"
|
||||
loading="lazy"
|
||||
></iframe>
|
||||
</div>
|
||||
|
||||
<!-- Bouton CTA -->
|
||||
<a
|
||||
href="https://liberapay.com/trans-former.fr/donate"
|
||||
@@ -95,16 +101,13 @@
|
||||
</section>
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════════
|
||||
SECTION 5 - Transparence IA
|
||||
SECTION 5 — Transparence IA
|
||||
══════════════════════════════════════════════════════════ -->
|
||||
<!-- TODO Jules : Écrire la posture sur l'IA - pourquoi ces modèles, pourquoi la transparence, ce que ça signifie politiquement -->
|
||||
<!-- 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.
|
||||
[Placeholder — Pourquoi afficher les coûts IA en clair. Ce que ça dit du projet.]
|
||||
</p>
|
||||
|
||||
<div class="ia-grid">
|
||||
@@ -121,85 +124,42 @@
|
||||
</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>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
|
||||
SECTION 6 — Contribuer
|
||||
══════════════════════════════════════════════════════════ -->
|
||||
<!-- TODO Jules : Écrire l'invitation à contribuer - ton, posture, ce qu'on attend des contributeurs -->
|
||||
<!-- 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.
|
||||
[Placeholder — Comment rejoindre le projet, ce qu'on peut apporter : fiches, corrections, soutien financier.]
|
||||
</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
|
||||
Soutenir financièrement
|
||||
</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' })
|
||||
useHead({ title: 'À propos — AEP' })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -350,6 +310,12 @@ useHead({ title: 'À propos - AEP' })
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.liberapay-widget-wrap {
|
||||
margin-bottom: 1rem;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.btn-liberapay {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -482,34 +448,6 @@ useHead({ title: 'À propos - AEP' })
|
||||
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) {
|
||||
|
||||
150
pages/admin/rag-status.vue
Normal file
150
pages/admin/rag-status.vue
Normal file
@@ -0,0 +1,150 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-gray-950 text-gray-100 p-8">
|
||||
<div class="max-w-2xl mx-auto">
|
||||
<h1 class="text-2xl font-bold mb-2 text-white">RAG Status - Admin</h1>
|
||||
<p class="text-gray-400 text-sm mb-8">Coexistence V1 (keyword NocoDB) + V2 (embeddings Mistral)</p>
|
||||
|
||||
<!-- Bloc V2 -->
|
||||
<div class="bg-gray-900 rounded-lg p-6 mb-4 border" :class="info?.v2_ready ? 'border-green-700' : 'border-yellow-700'">
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<span class="text-lg font-semibold">RAG V2 - Embeddings vectoriels</span>
|
||||
<span
|
||||
class="px-2 py-0.5 rounded text-xs font-mono"
|
||||
:class="info?.v2_ready ? 'bg-green-900 text-green-300' : 'bg-yellow-900 text-yellow-300'"
|
||||
>
|
||||
{{ info?.v2_ready ? 'ACTIF' : 'EN ATTENTE' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="info" class="space-y-2 text-sm text-gray-300">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500">Fiches vectorisées</span>
|
||||
<span class="font-mono">{{ info.v2_embeddings_count }} / 120</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500">Modèle embed</span>
|
||||
<span class="font-mono">{{ info.v2_model }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500">Modèle chat</span>
|
||||
<span class="font-mono">{{ info.model_chat }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500">Généré le</span>
|
||||
<span class="font-mono">{{ info.v2_generated_date ? info.v2_generated_date.slice(0, 10) : 'jamais' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!info?.v2_ready" class="mt-4 bg-gray-800 rounded p-3 text-xs font-mono text-yellow-300">
|
||||
{{ info?.setup_command ?? 'MISTRAL_API_KEY=xxx node scripts/vectorize-v2.js' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bloc V1 -->
|
||||
<div class="bg-gray-900 rounded-lg p-6 mb-6 border" :class="info?.v1_enabled ? 'border-blue-700' : 'border-gray-700'">
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<span class="text-lg font-semibold">RAG V1 - Keyword matching (NocoDB)</span>
|
||||
<span
|
||||
class="px-2 py-0.5 rounded text-xs font-mono"
|
||||
:class="info?.v1_enabled ? 'bg-blue-900 text-blue-300' : 'bg-gray-800 text-gray-500'"
|
||||
>
|
||||
{{ info?.v1_enabled ? 'ACTIF' : 'DÉSACTIVÉ' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="info" class="space-y-2 text-sm text-gray-300">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500">RAG_V1_ENABLED</span>
|
||||
<span class="font-mono">{{ info.v1_enabled }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500">Déprecation prévue</span>
|
||||
<span class="font-mono">{{ info.v1_deprecation_date }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Testeur chatbot-v2 -->
|
||||
<div class="bg-gray-900 rounded-lg p-6 border border-gray-700">
|
||||
<h2 class="text-base font-semibold mb-4">Tester /api/chatbot-v2</h2>
|
||||
|
||||
<div class="flex gap-2 mb-4">
|
||||
<input
|
||||
v-model="testQuestion"
|
||||
type="text"
|
||||
placeholder="Ex : structures de réemploi en Belgique"
|
||||
class="flex-1 bg-gray-800 border border-gray-600 rounded px-3 py-2 text-sm text-white placeholder-gray-500 focus:outline-none focus:border-blue-500"
|
||||
@keydown.enter="runTest"
|
||||
/>
|
||||
<button
|
||||
class="px-4 py-2 bg-blue-700 hover:bg-blue-600 rounded text-sm font-medium transition-colors disabled:opacity-50"
|
||||
:disabled="testLoading || !testQuestion.trim()"
|
||||
@click="runTest"
|
||||
>
|
||||
{{ testLoading ? 'En cours...' : 'Tester' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="testError" class="bg-red-900/50 border border-red-700 rounded p-3 text-sm text-red-300 mb-3">
|
||||
{{ testError }}
|
||||
</div>
|
||||
|
||||
<div v-if="testResult" class="space-y-3">
|
||||
<div class="bg-gray-800 rounded p-3 text-sm">
|
||||
<div class="text-gray-400 text-xs mb-1 font-mono">reponse_texte</div>
|
||||
<p class="text-gray-100 leading-relaxed">{{ testResult.reponse_texte }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="testResult.fiches_recommandees?.length" class="bg-gray-800 rounded p-3 text-sm">
|
||||
<div class="text-gray-400 text-xs mb-2 font-mono">fiches_recommandees</div>
|
||||
<div v-for="f in testResult.fiches_recommandees" :key="f.fiche_id" class="mb-2">
|
||||
<span class="font-mono text-blue-300 text-xs">{{ f.fiche_id }}</span>
|
||||
<span class="text-white ml-2">{{ f.nom }}</span>
|
||||
<p class="text-gray-400 text-xs mt-0.5">{{ f.explication }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="testResult.sources?.length" class="bg-gray-800 rounded p-3 text-sm">
|
||||
<div class="text-gray-400 text-xs mb-2 font-mono">sources (top-5 cosine)</div>
|
||||
<div v-for="s in testResult.sources" :key="s.fiche_id" class="flex justify-between text-xs mb-1">
|
||||
<span class="text-gray-300 font-mono">{{ s.fiche_id }}</span>
|
||||
<span class="text-blue-400 font-mono">{{ s.score?.toFixed(4) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-xs font-mono px-2" :class="testResult.v2_ready ? 'text-green-400' : 'text-yellow-400'">
|
||||
v2_ready: {{ testResult.v2_ready }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { data: info } = await useFetch('/api/admin/rag-info')
|
||||
|
||||
const testQuestion = ref('')
|
||||
const testResult = ref<any>(null)
|
||||
const testError = ref<string | null>(null)
|
||||
const testLoading = ref(false)
|
||||
|
||||
async function runTest() {
|
||||
if (!testQuestion.value.trim()) return
|
||||
testLoading.value = true
|
||||
testError.value = null
|
||||
testResult.value = null
|
||||
|
||||
try {
|
||||
const res = await $fetch('/api/chatbot-v2', {
|
||||
method: 'POST',
|
||||
body: { question: testQuestion.value }
|
||||
})
|
||||
testResult.value = res
|
||||
} catch (e: any) {
|
||||
testError.value = e?.data?.statusMessage ?? e?.message ?? 'Erreur inconnue'
|
||||
} finally {
|
||||
testLoading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -11,11 +11,7 @@
|
||||
<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.
|
||||
Soumets-la ici — une IA enrichira la fiche et Jules validera sous 7 jours.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -28,6 +24,10 @@
|
||||
Une IA va scraper le site et enrichir la description.
|
||||
Jules (et bientôt une équipe de modération) valide sous 7 jours.
|
||||
</p>
|
||||
<p v-if="trackingUrl" class="success-tracking">
|
||||
Tu peux suivre l'avancement ici :<br />
|
||||
<a :href="trackingUrl" class="tracking-link">{{ trackingUrl }}</a>
|
||||
</p>
|
||||
<button type="button" class="btn-secondary" @click="reset">
|
||||
Proposer une autre fiche
|
||||
</button>
|
||||
@@ -437,14 +437,6 @@ useHead({ title: 'Proposer une ressource — AEP' })
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -72,21 +72,6 @@ const { data: org, pending, error } = await useFetch<Org>(`/api/fiche/${orgId}`,
|
||||
key: `fiche-${orgId}`,
|
||||
})
|
||||
|
||||
// ── Fallback Pratiques regeneratives (bug E2E L1) ─────────────────────
|
||||
// Si /api/fiche/:id echoue, on regarde si l'id correspond a une pratique
|
||||
// regenerative et on redirige automatiquement vers /pratique/:id.
|
||||
if (error.value) {
|
||||
try {
|
||||
const pratiquesRes = await $fetch<{ list: { id: number }[] }>('/api/pratiques')
|
||||
const numericId = Number(orgId)
|
||||
if (!isNaN(numericId) && pratiquesRes.list?.some((p) => p.id === numericId)) {
|
||||
await navigateTo(`/pratique/${numericId}`, { replace: true })
|
||||
}
|
||||
} catch {
|
||||
// pas de fallback dispo, on garde l'erreur
|
||||
}
|
||||
}
|
||||
|
||||
// ── Commentaires — tick de rafraîchissement ───────────────────────────
|
||||
const commentRefreshTick = ref(0)
|
||||
|
||||
|
||||
481
pages/index-v1-backup.vue
Normal file
481
pages/index-v1-backup.vue
Normal file
@@ -0,0 +1,481 @@
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<!-- 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" style="color: var(--nav-text-muted);">Aucun résultat pour ces filtres.</p>
|
||||
</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)"
|
||||
>
|
||||
<span class="font-semibold text-sm leading-snug" style="color: var(--nav-text);">{{ org.nom }}</span>
|
||||
</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')
|
||||
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
|
||||
prevSelectedId.value = selectedId.value
|
||||
selectedId.value = firstId
|
||||
if (highlightTimer) clearTimeout(highlightTimer)
|
||||
highlightTimer = setTimeout(() => {
|
||||
selectedId.value = prevSelectedId.value
|
||||
prevSelectedId.value = null
|
||||
highlightTimer = null
|
||||
}, 5000)
|
||||
}
|
||||
|
||||
const mobileSearch = ref<string>((route.query.q as string) ?? '')
|
||||
const navMapRef = ref<any>(null)
|
||||
const navMapMobileRef = ref<any>(null)
|
||||
|
||||
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 })
|
||||
}
|
||||
|
||||
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
|
||||
if (typeof window !== 'undefined' && window.innerWidth >= 1024) {
|
||||
ficheModalId.value = id
|
||||
ficheModalOpen.value = true
|
||||
}
|
||||
}
|
||||
|
||||
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 })
|
||||
}
|
||||
|
||||
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])
|
||||
}
|
||||
}
|
||||
|
||||
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')
|
||||
|
||||
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) {
|
||||
result = result.filter((o) => {
|
||||
const orgFns = (o.tags_fonction ?? '').split(',').map((f) => f.trim()).filter(Boolean)
|
||||
return fonctions.value.some((fn) => orgFns.includes(fn))
|
||||
})
|
||||
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
|
||||
})
|
||||
|
||||
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
|
||||
})
|
||||
|
||||
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>
|
||||
@@ -1,170 +0,0 @@
|
||||
<template>
|
||||
<div class="min-h-screen" style="background: var(--nav-bg);">
|
||||
<div class="max-w-4xl mx-auto px-4 py-6">
|
||||
|
||||
<!-- ── Bouton retour carte (préserve filtres URL) ─── -->
|
||||
<NuxtLink
|
||||
:to="retourUrl"
|
||||
class="inline-flex items-center gap-1.5 text-sm mb-6 rounded-lg px-3 py-1.5 transition-colors"
|
||||
style="color: var(--nav-text); background: var(--nav-bg-alt);"
|
||||
aria-label="Retour aux pratiques régénératives"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<line x1="19" y1="12" x2="5" y2="12"/>
|
||||
<polyline points="12 19 5 12 12 5"/>
|
||||
</svg>
|
||||
Retour aux pratiques régénératives
|
||||
</NuxtLink>
|
||||
|
||||
<!-- ── Chargement ──────────────────────────────────── -->
|
||||
<div v-if="pending" class="py-16 text-center text-sm" style="color: var(--nav-text-muted);">
|
||||
Chargement de la fiche…
|
||||
</div>
|
||||
|
||||
<!-- ── Erreur ──────────────────────────────────────── -->
|
||||
<div v-else-if="!pratique" class="py-16 text-center">
|
||||
<p class="text-lg font-semibold mb-2" style="color: var(--nav-text);">Fiche introuvable</p>
|
||||
<p class="text-sm" style="color: var(--nav-text-muted);">La pratique demandée n'existe pas ou a été supprimée.</p>
|
||||
</div>
|
||||
|
||||
<!-- ── Contenu ─────────────────────────────────────── -->
|
||||
<template v-else>
|
||||
|
||||
<!-- Header fiche -->
|
||||
<div class="mb-6">
|
||||
<div class="flex items-start justify-between gap-4 mb-2">
|
||||
<h1 class="text-2xl font-bold leading-tight" style="color: var(--nav-text);">{{ pratique.nom }}</h1>
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<span
|
||||
class="px-2 py-1 rounded-full text-xs font-semibold uppercase tracking-wide"
|
||||
style="background: var(--nav-bg-alt); color: var(--nav-text-muted);"
|
||||
>{{ TYPES_ENTITE_LABELS[pratique.type] ?? pratique.type }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 flex-wrap mb-3">
|
||||
<span class="text-sm font-medium" style="color: var(--nav-text-muted);">
|
||||
{{ PAYS_LABELS[pratique.pays] ?? pratique.pays }}
|
||||
<template v-if="pratique.ville"> · {{ pratique.ville }}</template>
|
||||
</span>
|
||||
<span v-if="pratique.score" class="px-2 py-0.5 rounded text-xs" style="background: var(--nav-accent); color: var(--nav-text);">
|
||||
Score {{ pratique.score }}/5
|
||||
</span>
|
||||
<a
|
||||
v-if="pratique.url"
|
||||
:href="pratique.url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-sm underline"
|
||||
style="color: var(--nav-primary-solid);"
|
||||
>Site web →</a>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<p v-if="pratique.description" class="text-sm leading-relaxed" style="color: var(--nav-text);">
|
||||
{{ pratique.description }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Séparateur -->
|
||||
<div class="mb-6" style="height: 1px; background: var(--nav-bg-alt);"></div>
|
||||
|
||||
<!-- Critères régénératifs -->
|
||||
<div v-if="pratique.criteres?.length" class="mb-6">
|
||||
<h2 class="text-xs font-bold uppercase tracking-wide mb-3" style="color: var(--nav-text-muted);">Critères régénératifs</h2>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<span
|
||||
v-for="cId in pratique.criteres"
|
||||
:key="cId"
|
||||
class="px-3 py-1 rounded-full text-sm font-medium"
|
||||
style="background: var(--nav-primary); color: var(--nav-text-on-primary);"
|
||||
>
|
||||
{{ CRITERES.find(c => c.id === cId)?.label ?? `Critère ${cId}` }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tags -->
|
||||
<div v-if="pratique.tags?.length" class="mb-6">
|
||||
<h2 class="text-xs font-bold uppercase tracking-wide mb-3" style="color: var(--nav-text-muted);">Tags</h2>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<span
|
||||
v-for="tag in pratique.tags"
|
||||
:key="tag"
|
||||
class="px-2 py-0.5 rounded text-xs"
|
||||
style="background: var(--nav-bg-alt); color: var(--nav-text-muted);"
|
||||
>{{ tag }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Métadonnées -->
|
||||
<div class="mb-6">
|
||||
<h2 class="text-xs font-bold uppercase tracking-wide mb-3" style="color: var(--nav-text-muted);">Informations</h2>
|
||||
<dl class="space-y-1.5">
|
||||
<div v-if="pratique.passe" class="flex gap-2 text-sm">
|
||||
<dt style="color: var(--nav-text-muted);">Passe :</dt>
|
||||
<dd style="color: var(--nav-text);">{{ pratique.passe }}</dd>
|
||||
</div>
|
||||
<div v-if="pratique.source" class="flex gap-2 text-sm">
|
||||
<dt style="color: var(--nav-text-muted);">Source :</dt>
|
||||
<dd style="color: var(--nav-text);">{{ pratique.source }}</dd>
|
||||
</div>
|
||||
<div v-if="pratique.lat != null && pratique.lng != null" class="flex gap-2 text-sm">
|
||||
<dt style="color: var(--nav-text-muted);">Coordonnées :</dt>
|
||||
<dd style="color: var(--nav-text);">{{ pratique.lat }}, {{ pratique.lng }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Pratique } from '~/types/pratique'
|
||||
import { CRITERES, TYPES_ENTITE_LABELS, PAYS_LABELS } from '~/types/pratique'
|
||||
|
||||
// ── Params & route ────────────────────────────────────────────────────
|
||||
const route = useRoute()
|
||||
const pratiqueId = route.params.id as string
|
||||
|
||||
// ── Retour carte — préserve les filtres via sessionStorage ────────────
|
||||
const retourUrl = ref('/pratiques-regeneratives')
|
||||
|
||||
onMounted(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const stored = sessionStorage.getItem('pratiques_back_filters')
|
||||
if (stored) {
|
||||
retourUrl.value = `/pratiques-regeneratives?${stored}`
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// ── Fetch toutes les pratiques et trouver la bonne ───────────────────
|
||||
const { data, pending } = await useFetch<{ list: Pratique[]; source: string }>('/api/pratiques', {
|
||||
key: `pratiques-all`,
|
||||
})
|
||||
|
||||
const pratique = computed<Pratique | null>(() => {
|
||||
const id = parseInt(pratiqueId, 10)
|
||||
if (isNaN(id)) return null
|
||||
return data.value?.list?.find(p => p.id === id) ?? null
|
||||
})
|
||||
|
||||
// ── SEO dynamiques ────────────────────────────────────────────────────
|
||||
useHead({
|
||||
title: computed(() =>
|
||||
pratique.value ? `${pratique.value.nom} — Pratiques régénératives — AEP` : 'Pratique régénérative — AEP'
|
||||
),
|
||||
meta: [
|
||||
{
|
||||
name: 'description',
|
||||
content: computed(() =>
|
||||
pratique.value?.description?.substring(0, 160).trim() ?? 'Pratique régénérative — AEP'
|
||||
),
|
||||
},
|
||||
],
|
||||
})
|
||||
</script>
|
||||
@@ -1,564 +0,0 @@
|
||||
<template>
|
||||
<div class="flex h-full overflow-hidden" style="background: var(--nav-bg);">
|
||||
|
||||
<!-- ═══════════════════════════════════════ SIDEBAR DESKTOP (≥ 1024px) -->
|
||||
<div class="hidden lg:flex w-80 shrink-0 flex-col overflow-hidden">
|
||||
<PratiqueSidebar
|
||||
:search="search"
|
||||
:criteres="criteres"
|
||||
:typesEntite="typesEntite"
|
||||
:critereCount="critereCount"
|
||||
:typeCount="typeCount"
|
||||
:resultCount="filtered.length"
|
||||
:pratiques="filtered"
|
||||
:selectedId="selectedId"
|
||||
:hasActiveFilters="hasActiveFilters"
|
||||
:pending="pending"
|
||||
@update:search="onSearch"
|
||||
@update:criteres="onCriteres"
|
||||
@update:typesEntite="onTypesEntite"
|
||||
@select-pratique="onSelectPratique"
|
||||
@hover-pratique="onHoverPratique"
|
||||
@reset-filters="resetFilters"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════════════════════════════════ ZONE CENTRALE (carte) -->
|
||||
<main class="flex-1 flex flex-col overflow-hidden relative">
|
||||
|
||||
<!-- ── VUE DESKTOP : Onglets Europe / Outre-mer + carte pleine hauteur ── -->
|
||||
<div class="hidden lg:flex lg:flex-1 lg:flex-col lg:overflow-hidden">
|
||||
|
||||
<!-- Onglets Europe / Outre-mer (desktop) -->
|
||||
<div class="shrink-0 flex" style="background: var(--nav-surface); border-bottom: 1px solid var(--nav-bg-alt);">
|
||||
<button
|
||||
type="button"
|
||||
class="flex-1 py-2 text-sm font-medium transition-colors"
|
||||
:style="desktopMapView === 'europe'
|
||||
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
|
||||
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
|
||||
@click="desktopMapView = 'europe'"
|
||||
>
|
||||
Europe
|
||||
<span class="ml-1 text-xs opacity-70">({{ europeOrgs.length }})</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="flex-1 py-2 text-sm font-medium transition-colors"
|
||||
:style="desktopMapView === 'outremer'
|
||||
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
|
||||
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
|
||||
@click="desktopMapView = 'outremer'"
|
||||
>
|
||||
Outre-mer
|
||||
<span class="ml-1 text-xs opacity-70">({{ outremerOrgs.length }})</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Carte Europe (pleine hauteur) -->
|
||||
<div v-show="desktopMapView === 'europe'" class="flex flex-col flex-1 overflow-hidden">
|
||||
<div class="relative flex-1" style="min-height: 200px;">
|
||||
<ClientOnly>
|
||||
<EuropeMap
|
||||
ref="europeMapRef"
|
||||
:orgs="europeOrgs"
|
||||
:selectedId="selectedId"
|
||||
@select-org="onSelectPratique"
|
||||
/>
|
||||
<template #fallback>
|
||||
<div
|
||||
class="w-full h-full flex items-center justify-center"
|
||||
style="background: var(--nav-bg-alt); color: var(--nav-text-muted);"
|
||||
>
|
||||
Chargement de la carte…
|
||||
</div>
|
||||
</template>
|
||||
</ClientOnly>
|
||||
</div>
|
||||
<ChatbotPlaceholder
|
||||
endpoint="/api/chatbot-pratiques"
|
||||
title="Chatbot Pratiques régé"
|
||||
placeholder="Pose une question sur les pratiques régénératives…"
|
||||
ficheBasePath="/pratique"
|
||||
@highlightOrgs="onHighlightOrgs"
|
||||
>
|
||||
<template #onboarding>
|
||||
<p>Ce chatbot interroge la base des pratiques régénératives
|
||||
(Mistral FR, serveur européen souverain, zéro rétention).</p>
|
||||
<p>Pour t'aider à trouver les pratiques pertinentes,
|
||||
formule ta requête ainsi :</p>
|
||||
<ul>
|
||||
<li>• Besoin : [matériaux biosourcés / réemploi / posture politique...]</li>
|
||||
<li>• Type : [agence / coopérative / collectif / réseau...]</li>
|
||||
<li>• Lieu : [pays ou région]</li>
|
||||
</ul>
|
||||
<p class="example">Exemple : "Je cherche une coopérative qui travaille
|
||||
le réemploi de matériaux en Belgique."</p>
|
||||
</template>
|
||||
</ChatbotPlaceholder>
|
||||
</div>
|
||||
|
||||
<!-- Carte Outre-mer (pleine hauteur, scroll) -->
|
||||
<div v-show="desktopMapView === 'outremer'" class="flex-1 overflow-y-auto" style="background: var(--nav-bg);">
|
||||
<ClientOnly>
|
||||
<OutremerMapPratiques
|
||||
:orgs="outremerOrgs"
|
||||
:selectedId="selectedId"
|
||||
@select-org="onSelectPratique"
|
||||
/>
|
||||
<template #fallback>
|
||||
<div
|
||||
class="flex items-center justify-center h-48 text-sm"
|
||||
style="color: var(--nav-text-muted);"
|
||||
>
|
||||
Chargement…
|
||||
</div>
|
||||
</template>
|
||||
</ClientOnly>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── VUE MOBILE : Onglets Europe/Outre-mer + carte pleine hauteur + sheet swipable ── -->
|
||||
|
||||
<!-- Onglets Europe / Outre-mer -->
|
||||
<div class="lg:hidden shrink-0 flex" style="background: var(--nav-surface); border-bottom: 1px solid var(--nav-bg-alt);">
|
||||
<button
|
||||
class="flex-1 py-2 text-sm font-medium transition-colors"
|
||||
:style="mobileMapView === 'europe'
|
||||
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
|
||||
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
|
||||
@click="mobileMapView = 'europe'"
|
||||
>Europe</button>
|
||||
<button
|
||||
class="flex-1 py-2 text-sm font-medium transition-colors"
|
||||
:style="mobileMapView === 'outremer'
|
||||
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
|
||||
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
|
||||
@click="mobileMapView = 'outremer'"
|
||||
>Outre-mer</button>
|
||||
</div>
|
||||
|
||||
<div class="lg:hidden flex-1 relative overflow-hidden">
|
||||
|
||||
<!-- Carte Europe -->
|
||||
<div v-show="mobileMapView === 'europe'" class="absolute inset-0">
|
||||
<ClientOnly>
|
||||
<EuropeMap
|
||||
ref="europeMapMobileRef"
|
||||
:orgs="europeOrgs"
|
||||
:selectedId="selectedId"
|
||||
@select-org="onSelectPratiqueMobile"
|
||||
/>
|
||||
<template #fallback>
|
||||
<div
|
||||
class="w-full h-full flex items-center justify-center"
|
||||
style="background: var(--nav-bg-alt); color: var(--nav-text-muted);"
|
||||
>
|
||||
Chargement de la carte…
|
||||
</div>
|
||||
</template>
|
||||
</ClientOnly>
|
||||
</div>
|
||||
|
||||
<!-- Carte Outre-mer -->
|
||||
<div v-show="mobileMapView === 'outremer'" class="absolute inset-0 overflow-y-auto" style="background: var(--nav-bg);">
|
||||
<ClientOnly>
|
||||
<OutremerMapPratiques
|
||||
:orgs="outremerOrgs"
|
||||
:selectedId="selectedId"
|
||||
@select-org="onSelectPratiqueMobile"
|
||||
/>
|
||||
<template #fallback>
|
||||
<div class="flex items-center justify-center h-48" style="color: var(--nav-text-muted);">
|
||||
Chargement…
|
||||
</div>
|
||||
</template>
|
||||
</ClientOnly>
|
||||
</div>
|
||||
|
||||
<!-- Bottom sheet swipable (Europe et Outre-mer) -->
|
||||
<ClientOnly>
|
||||
<MobileSheet :resultCount="filtered.length" :pending="pending">
|
||||
<!-- Barre recherche -->
|
||||
<div class="px-3 pt-2 pb-2" style="border-bottom: 1px solid var(--nav-bg-alt);">
|
||||
<label class="mobile-search-label" aria-label="Rechercher une pratique">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="color: var(--nav-text-muted); flex-shrink: 0;">
|
||||
<circle cx="11" cy="11" r="8"/>
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
|
||||
</svg>
|
||||
<input
|
||||
v-model="mobileSearch"
|
||||
type="search"
|
||||
placeholder="Rechercher…"
|
||||
class="mobile-search-input"
|
||||
autocomplete="off"
|
||||
@input="onSearch(mobileSearch)"
|
||||
/>
|
||||
<button
|
||||
v-if="mobileSearch"
|
||||
type="button"
|
||||
class="mobile-search-clear"
|
||||
aria-label="Effacer"
|
||||
@click.stop="mobileSearch = ''; onSearch('')"
|
||||
>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
</label>
|
||||
|
||||
<!-- Filtres CRITÈRES — chips -->
|
||||
<div class="mt-2">
|
||||
<span class="text-xs font-bold uppercase tracking-wide block mb-1" style="color: var(--nav-text-muted);">CRITÈRES</span>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<button
|
||||
v-for="c in CRITERES"
|
||||
:key="c.id"
|
||||
type="button"
|
||||
class="cursor-pointer px-2 py-0.5 rounded-full text-xs transition-all"
|
||||
:style="criteres.includes(c.id)
|
||||
? 'background: var(--nav-primary); color: var(--nav-text-on-primary); font-weight: 600;'
|
||||
: 'background: var(--nav-bg-alt); color: var(--nav-text-muted);'"
|
||||
:aria-pressed="criteres.includes(c.id)"
|
||||
@click="toggleCritere(c.id)"
|
||||
>{{ c.label }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filtres TYPE — chips -->
|
||||
<div class="mt-2">
|
||||
<span class="text-xs font-bold uppercase tracking-wide block mb-1" style="color: var(--nav-text-muted);">TYPE</span>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<button
|
||||
v-for="t in TYPES_ENTITE"
|
||||
:key="t"
|
||||
type="button"
|
||||
class="cursor-pointer px-2 py-0.5 rounded-full text-xs transition-all"
|
||||
:style="typesEntite.includes(t)
|
||||
? 'background: var(--nav-primary); color: var(--nav-text-on-primary); font-weight: 600;'
|
||||
: 'background: var(--nav-bg-alt); color: var(--nav-text-muted);'"
|
||||
:aria-pressed="typesEntite.includes(t)"
|
||||
@click="toggleType(t)"
|
||||
>{{ TYPES_ENTITE_LABELS[t] ?? t }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-if="hasActiveFilters"
|
||||
@click="resetFilters"
|
||||
class="mt-2 text-xs"
|
||||
style="color: var(--nav-text-muted); text-decoration: underline;"
|
||||
>Effacer les filtres</button>
|
||||
</div>
|
||||
|
||||
<!-- Compteur + Liste fiches -->
|
||||
<div class="px-3 py-2">
|
||||
<div class="text-xs font-bold uppercase tracking-wide mb-2" style="color: var(--nav-text-muted);">
|
||||
{{ filtered.length }} résultat{{ filtered.length > 1 ? 's' : '' }}
|
||||
</div>
|
||||
<div v-if="pending" class="flex items-center justify-center py-8" style="color: var(--nav-text-muted);">
|
||||
Chargement des fiches…
|
||||
</div>
|
||||
<div v-else-if="filtered.length === 0" class="text-center py-8">
|
||||
<p class="text-sm mb-2" style="color: var(--nav-text-muted);">Aucun résultat pour ces filtres.</p>
|
||||
<button @click="resetFilters" class="text-sm underline" style="color: var(--nav-primary-solid);">
|
||||
Effacer les filtres
|
||||
</button>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<div
|
||||
v-for="pratique in filtered"
|
||||
:key="pratique.id"
|
||||
class="block rounded-lg p-3 transition-all cursor-pointer"
|
||||
:style="selectedId === pratique.id
|
||||
? 'background: var(--nav-bg-alt); border-left: 3px solid var(--nav-accent);'
|
||||
: 'background: var(--nav-surface); border-left: 3px solid transparent;'"
|
||||
@click="onSelectPratiqueMobile(pratique.id)"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<span class="font-semibold text-sm leading-snug" style="color: var(--nav-text);">{{ pratique.nom }}</span>
|
||||
<span
|
||||
v-if="pratique.pays"
|
||||
class="shrink-0 px-2 py-0.5 rounded-full text-xs font-medium"
|
||||
style="background: var(--nav-bg-alt); color: var(--nav-text-muted);"
|
||||
>{{ pratique.pays }}</span>
|
||||
</div>
|
||||
<div v-if="pratique.criteres?.length" class="mt-1 flex flex-wrap gap-1">
|
||||
<span
|
||||
v-for="cId in pratique.criteres.slice(0, 3)"
|
||||
:key="cId"
|
||||
class="px-1.5 py-0.5 rounded text-xs"
|
||||
style="background: var(--nav-bg-alt); color: var(--nav-text-muted);"
|
||||
>{{ CRITERES.find(c => c.id === cId)?.label }}</span>
|
||||
</div>
|
||||
<div v-if="pratique.ville" class="mt-1 text-xs" style="color: var(--nav-text-muted);">
|
||||
{{ pratique.ville }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</MobileSheet>
|
||||
</ClientOnly>
|
||||
</div>
|
||||
|
||||
</main>
|
||||
|
||||
<!-- ═══════════════════════════════════════ BOUTON CHATBOT FLOTTANT (mobile) -->
|
||||
<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"
|
||||
endpoint="/api/chatbot-pratiques"
|
||||
title="Chatbot Pratiques régé"
|
||||
ficheBasePath="/pratique"
|
||||
@update:modelValue="chatbotOpen = $event"
|
||||
@highlightOrgs="onHighlightOrgs"
|
||||
>
|
||||
<template #onboarding>
|
||||
<p>Ce chatbot interroge la base des pratiques régénératives
|
||||
(Mistral FR, serveur européen souverain, zéro rétention).</p>
|
||||
<p>Pour t'aider à trouver les pratiques pertinentes,
|
||||
formule ta requête ainsi :</p>
|
||||
<ul>
|
||||
<li>• Besoin : [matériaux biosourcés / réemploi / posture politique...]</li>
|
||||
<li>• Type : [agence / coopérative / collectif / réseau...]</li>
|
||||
<li>• Lieu : [pays ou région]</li>
|
||||
</ul>
|
||||
<p class="example">Exemple : "Je cherche une coopérative qui travaille
|
||||
le réemploi de matériaux en Belgique."</p>
|
||||
</template>
|
||||
</ChatbotSheet>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Pratique } from '~/types/pratique'
|
||||
import { CRITERES, TYPES_ENTITE, TYPES_ENTITE_LABELS, EUROPE_CODES, OUTREMER_CODES } from '~/types/pratique'
|
||||
|
||||
// ── URL query params sync ─────────────────────────────────────────────────
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const search = ref<string>((route.query.q as string) ?? '')
|
||||
const criteres = ref<number[]>(
|
||||
route.query.criteres
|
||||
? (route.query.criteres as string).split(',').map(Number).filter(Boolean)
|
||||
: []
|
||||
)
|
||||
const typesEntite = ref<string[]>(
|
||||
route.query.types
|
||||
? (route.query.types as string).split(',').filter(Boolean)
|
||||
: []
|
||||
)
|
||||
const pays = ref<string[]>(
|
||||
route.query.pays
|
||||
? (route.query.pays as string).split(',').filter(Boolean)
|
||||
: []
|
||||
)
|
||||
|
||||
const selectedId = ref<number | null>(null)
|
||||
const mobileMapView = ref<'europe' | 'outremer'>('europe')
|
||||
const desktopMapView = ref<'europe' | 'outremer'>('europe')
|
||||
const chatbotOpen = ref(false)
|
||||
|
||||
// Surlignage temporaire (5 sec) suite a une reponse chatbot
|
||||
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
|
||||
|
||||
prevSelectedId.value = selectedId.value
|
||||
selectedId.value = firstId
|
||||
|
||||
if (highlightTimer) clearTimeout(highlightTimer)
|
||||
highlightTimer = setTimeout(() => {
|
||||
selectedId.value = prevSelectedId.value
|
||||
prevSelectedId.value = null
|
||||
highlightTimer = null
|
||||
}, 5000)
|
||||
}
|
||||
|
||||
// Refs vers les instances EuropeMap
|
||||
const europeMapRef = ref<any>(null)
|
||||
const europeMapMobileRef = ref<any>(null)
|
||||
|
||||
// Ref locale barre de recherche mobile
|
||||
const mobileSearch = ref<string>((route.query.q as string) ?? '')
|
||||
|
||||
// Sync URL <-> état filtres
|
||||
function syncUrl() {
|
||||
const q: Record<string, string> = {}
|
||||
if (search.value) q.q = search.value
|
||||
if (criteres.value.length) q.criteres = criteres.value.join(',')
|
||||
if (typesEntite.value.length) q.types = typesEntite.value.join(',')
|
||||
if (pays.value.length) q.pays = pays.value.join(',')
|
||||
router.replace({ query: Object.keys(q).length ? q : undefined })
|
||||
}
|
||||
|
||||
// Sauvegarde filtres pour bouton retour des fiches
|
||||
function storeFiltersForBack() {
|
||||
if (typeof window === 'undefined') return
|
||||
const q: Record<string, string> = {}
|
||||
if (search.value) q.q = search.value
|
||||
if (criteres.value.length) q.criteres = criteres.value.join(',')
|
||||
if (typesEntite.value.length) q.types = typesEntite.value.join(',')
|
||||
if (pays.value.length) q.pays = pays.value.join(',')
|
||||
const qs = new URLSearchParams(q).toString()
|
||||
sessionStorage.setItem('pratiques_back_filters', qs)
|
||||
}
|
||||
|
||||
function onSearch(v: string) { search.value = v; syncUrl(); storeFiltersForBack() }
|
||||
function onCriteres(v: number[]) { criteres.value = v; syncUrl(); storeFiltersForBack() }
|
||||
function onTypesEntite(v: string[]) { typesEntite.value = v; syncUrl(); storeFiltersForBack() }
|
||||
function onPays(v: string[]) { pays.value = v; syncUrl(); storeFiltersForBack() }
|
||||
|
||||
function onSelectPratique(id: number) {
|
||||
selectedId.value = selectedId.value === id ? null : id
|
||||
// Desktop : naviguer vers la fiche
|
||||
if (typeof window !== 'undefined' && window.innerWidth >= 1024) {
|
||||
storeFiltersForBack()
|
||||
router.push(`/pratique/${id}`)
|
||||
}
|
||||
}
|
||||
|
||||
function onSelectPratiqueMobile(id: number) {
|
||||
selectedId.value = id
|
||||
storeFiltersForBack()
|
||||
router.push(`/pratique/${id}`)
|
||||
}
|
||||
|
||||
function onHoverPratique(id: number | null) {
|
||||
if (id !== null) selectedId.value = id
|
||||
}
|
||||
|
||||
const hasActiveFilters = computed(() =>
|
||||
!!search.value || criteres.value.length > 0 || typesEntite.value.length > 0 || pays.value.length > 0
|
||||
)
|
||||
|
||||
function resetFilters() {
|
||||
search.value = ''
|
||||
mobileSearch.value = ''
|
||||
criteres.value = []
|
||||
typesEntite.value = []
|
||||
pays.value = []
|
||||
router.replace({ query: undefined })
|
||||
}
|
||||
|
||||
function toggleCritere(id: number) {
|
||||
if (criteres.value.includes(id)) {
|
||||
onCriteres(criteres.value.filter(v => v !== id))
|
||||
} else {
|
||||
onCriteres([...criteres.value, id])
|
||||
}
|
||||
}
|
||||
|
||||
function toggleType(t: string) {
|
||||
if (typesEntite.value.includes(t)) {
|
||||
onTypesEntite(typesEntite.value.filter(v => v !== t))
|
||||
} else {
|
||||
onTypesEntite([...typesEntite.value, t])
|
||||
}
|
||||
}
|
||||
|
||||
// Sync recherche depuis URL ?q=
|
||||
watch(() => route.query.q, (v) => {
|
||||
search.value = (v as string) ?? ''
|
||||
})
|
||||
|
||||
// ── Données ───────────────────────────────────────────────────────────────
|
||||
const { data, pending, error: fetchError } = await useFetch<{ list: Pratique[]; source: string }>('/api/pratiques')
|
||||
|
||||
const pratiques = computed<Pratique[]>(() => data.value?.list ?? [])
|
||||
|
||||
// ── Filtrage côté client ──────────────────────────────────────────────────
|
||||
const filtered = computed<Pratique[]>(() => {
|
||||
let result = pratiques.value
|
||||
|
||||
if (search.value.trim()) {
|
||||
const q = search.value.toLowerCase()
|
||||
result = result.filter(
|
||||
(o) =>
|
||||
o.nom?.toLowerCase().includes(q) ||
|
||||
o.ville?.toLowerCase().includes(q) ||
|
||||
o.description?.toLowerCase().includes(q)
|
||||
)
|
||||
}
|
||||
|
||||
if (criteres.value.length) {
|
||||
result = result.filter((o) =>
|
||||
criteres.value.some((cId) => o.criteres?.includes(cId))
|
||||
)
|
||||
// Tri par score pondéré : priorité au premier critère cliqué
|
||||
const n = criteres.value.length
|
||||
const score = (o: Pratique) =>
|
||||
criteres.value.reduce((s, cId, i) => {
|
||||
return s + (o.criteres?.includes(cId) ? (n - i) : 0)
|
||||
}, 0)
|
||||
result = [...result].sort((a, b) => score(b) - score(a))
|
||||
}
|
||||
|
||||
if (typesEntite.value.length) {
|
||||
result = result.filter((o) => o.type && typesEntite.value.includes(o.type))
|
||||
}
|
||||
|
||||
if (pays.value.length) {
|
||||
result = result.filter((o) => o.pays && pays.value.includes(o.pays))
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
// Séparation Europe / Outre-mer
|
||||
const europeOrgs = computed<Pratique[]>(() =>
|
||||
filtered.value.filter(o => !o.pays || (EUROPE_CODES as readonly string[]).includes(o.pays))
|
||||
)
|
||||
|
||||
const outremerOrgs = computed<Pratique[]>(() =>
|
||||
filtered.value.filter(o => o.pays && (OUTREMER_CODES as readonly string[]).includes(o.pays))
|
||||
)
|
||||
|
||||
// ── Compteurs ─────────────────────────────────────────────────────────────
|
||||
const critereCount = computed<Record<number, number>>(() => {
|
||||
const counts: Record<number, number> = {}
|
||||
CRITERES.forEach(c => { counts[c.id] = 0 })
|
||||
pratiques.value.forEach(o => {
|
||||
o.criteres?.forEach(cId => { counts[cId] = (counts[cId] ?? 0) + 1 })
|
||||
})
|
||||
return counts
|
||||
})
|
||||
|
||||
const typeCount = computed<Record<string, number>>(() => {
|
||||
const counts: Record<string, number> = {}
|
||||
TYPES_ENTITE.forEach(t => { counts[t] = 0 })
|
||||
pratiques.value.forEach(o => {
|
||||
if (o.type) counts[o.type] = (counts[o.type] ?? 0) + 1
|
||||
})
|
||||
return counts
|
||||
})
|
||||
|
||||
useHead({ title: 'AEP — Pratiques régénératives en Europe' })
|
||||
</script>
|
||||
@@ -1,833 +0,0 @@
|
||||
<template>
|
||||
<div class="contribuer-page">
|
||||
<div class="contribuer-inner">
|
||||
<!-- Retour -->
|
||||
<NuxtLink to="/pratiques-regeneratives" class="back-link">
|
||||
← Retour aux pratiques régénératives
|
||||
</NuxtLink>
|
||||
|
||||
<!-- En-tête -->
|
||||
<div class="contribuer-header">
|
||||
<h1>Proposer une pratique</h1>
|
||||
<p class="contribuer-subtitle">
|
||||
Tu connais une agence, un collectif ou un réseau qui incarne l'architecture régénérative ?
|
||||
Soumets-le ici — Jules valide manuellement les nouvelles entrées.
|
||||
</p>
|
||||
<p class="contribuer-hint">
|
||||
Si tu n'as pas le temps de tout remplir, laisse-nous juste le lien.
|
||||
Mais une description de ta main, c'est toujours plus vivant.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Message succès -->
|
||||
<div v-if="success" class="success-block" role="status" aria-live="polite">
|
||||
<div class="success-icon">✓</div>
|
||||
<h2>Merci !</h2>
|
||||
<p>Ta proposition est en attente de modération.</p>
|
||||
<p class="success-detail">
|
||||
Jules valide manuellement chaque entrée avant publication.
|
||||
</p>
|
||||
<button type="button" class="btn-secondary" @click="reset">
|
||||
Proposer une autre pratique
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Formulaire -->
|
||||
<form v-else @submit.prevent="submit" class="contribuer-form" novalidate>
|
||||
|
||||
<!-- Nom -->
|
||||
<div class="field-group" :class="{ 'field-error': errors.nom }">
|
||||
<label for="nom">Nom de l'organisation <span class="required">*</span></label>
|
||||
<input
|
||||
id="nom"
|
||||
v-model="form.nom"
|
||||
type="text"
|
||||
placeholder="Ex : Lacaton & Vassal, Plateau Urbain..."
|
||||
autocomplete="organization"
|
||||
@blur="validateField('nom')"
|
||||
/>
|
||||
<span v-if="errors.nom" class="error-msg" role="alert">{{ errors.nom }}</span>
|
||||
</div>
|
||||
|
||||
<!-- URL -->
|
||||
<div class="field-group" :class="{ 'field-error': errors.url }">
|
||||
<label for="url">
|
||||
Site web
|
||||
<span class="label-hint">(optionnel — recommandé)</span>
|
||||
</label>
|
||||
<input
|
||||
id="url"
|
||||
v-model="form.url"
|
||||
type="url"
|
||||
placeholder="https://..."
|
||||
@blur="validateField('url')"
|
||||
/>
|
||||
<span v-if="errors.url" class="error-msg" role="alert">{{ errors.url }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div class="field-group" :class="{ 'field-error': errors.description_user }">
|
||||
<label for="description_user">
|
||||
Description courte <span class="required">*</span>
|
||||
<span class="label-hint">(50 à 500 caractères)</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="description_user"
|
||||
v-model="form.description_user"
|
||||
rows="4"
|
||||
placeholder="Décris cette pratique : approche, matériaux, posture, ce qui la rend régénérative..."
|
||||
@blur="validateField('description_user')"
|
||||
/>
|
||||
<div class="field-meta">
|
||||
<span v-if="errors.description_user" class="error-msg" role="alert">
|
||||
{{ errors.description_user }}
|
||||
</span>
|
||||
<span v-else class="char-count" :class="{ 'char-warn': form.description_user.length > 450 }">
|
||||
{{ form.description_user.length }}/500
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Critères régénératifs -->
|
||||
<div class="field-group" :class="{ 'field-error': errors.criteres }">
|
||||
<fieldset>
|
||||
<legend>
|
||||
Critères régénératifs <span class="required">*</span>
|
||||
<span class="label-hint">(3 minimum, 8 maximum)</span>
|
||||
</legend>
|
||||
<div class="checkbox-grid">
|
||||
<label
|
||||
v-for="c in CRITERES"
|
||||
:key="c.id"
|
||||
class="checkbox-label"
|
||||
:class="{
|
||||
active: form.criteres.includes(c.id),
|
||||
disabled: !form.criteres.includes(c.id) && form.criteres.length >= 8,
|
||||
}"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
:value="c.id"
|
||||
:checked="form.criteres.includes(c.id)"
|
||||
:disabled="!form.criteres.includes(c.id) && form.criteres.length >= 8"
|
||||
@change="toggleCritere(c.id)"
|
||||
/>
|
||||
{{ c.label }}
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
<span v-if="errors.criteres" class="error-msg" role="alert">{{ errors.criteres }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Type d'entité -->
|
||||
<div class="field-group" :class="{ 'field-error': errors.type }">
|
||||
<fieldset>
|
||||
<legend>
|
||||
Type d'entité <span class="required">*</span>
|
||||
</legend>
|
||||
<div class="radio-group">
|
||||
<label
|
||||
v-for="t in TYPES_ENTITE"
|
||||
:key="t"
|
||||
class="radio-label"
|
||||
:class="{ active: form.type === t }"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
:value="t"
|
||||
v-model="form.type"
|
||||
name="type"
|
||||
@change="validateField('type')"
|
||||
/>
|
||||
{{ TYPES_ENTITE_LABELS[t] }}
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
<span v-if="errors.type" class="error-msg" role="alert">{{ errors.type }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Pays -->
|
||||
<div class="field-group" :class="{ 'field-error': errors.pays }">
|
||||
<label for="pays">
|
||||
Pays <span class="required">*</span>
|
||||
</label>
|
||||
<select
|
||||
id="pays"
|
||||
v-model="form.pays"
|
||||
@change="validateField('pays')"
|
||||
>
|
||||
<option value="" disabled>Sélectionne un pays...</option>
|
||||
<optgroup label="Europe">
|
||||
<option v-for="code in EUROPE_CODES" :key="code" :value="code">
|
||||
{{ PAYS_LABELS[code] }}
|
||||
</option>
|
||||
</optgroup>
|
||||
<optgroup label="DOM-TOM">
|
||||
<option v-for="code in OUTREMER_CODES" :key="code" :value="code">
|
||||
{{ PAYS_LABELS[code] }}
|
||||
</option>
|
||||
</optgroup>
|
||||
<optgroup label="Autre">
|
||||
<option value="AUTRE">Autre pays...</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
<span v-if="errors.pays" class="error-msg" role="alert">{{ errors.pays }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Pays autre (conditionnel) -->
|
||||
<div v-if="form.pays === 'AUTRE'" class="field-group" :class="{ 'field-error': errors.pays_autre }">
|
||||
<label for="pays_autre">Précise le pays</label>
|
||||
<input
|
||||
id="pays_autre"
|
||||
v-model="form.pays_autre"
|
||||
type="text"
|
||||
placeholder="Ex : Maroc, Brésil..."
|
||||
maxlength="50"
|
||||
/>
|
||||
<span v-if="errors.pays_autre" class="error-msg" role="alert">{{ errors.pays_autre }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Ville -->
|
||||
<div class="field-group" :class="{ 'field-error': errors.ville }">
|
||||
<label for="ville">
|
||||
Ville principale
|
||||
<span class="label-hint">(optionnel)</span>
|
||||
</label>
|
||||
<input
|
||||
id="ville"
|
||||
v-model="form.ville"
|
||||
type="text"
|
||||
placeholder="Ex : Paris, Bordeaux, Bruxelles..."
|
||||
/>
|
||||
<span v-if="errors.ville" class="error-msg" role="alert">{{ errors.ville }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Tags -->
|
||||
<div class="field-group" :class="{ 'field-error': errors.tags }">
|
||||
<label for="tags">
|
||||
Tags
|
||||
<span class="label-hint">(optionnel — 3 à 6 mots-clés, séparés par des virgules)</span>
|
||||
</label>
|
||||
<input
|
||||
id="tags"
|
||||
v-model="tagsInput"
|
||||
type="text"
|
||||
placeholder="Ex : biosourcé, réhabilitation, circuit-court"
|
||||
@blur="parseTags"
|
||||
/>
|
||||
<span v-if="errors.tags" class="error-msg" role="alert">{{ errors.tags }}</span>
|
||||
<div v-if="form.tags && form.tags.length" class="tags-preview">
|
||||
<span v-for="tag in form.tags" :key="tag" class="tag-chip">{{ tag }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Email -->
|
||||
<div class="field-group" :class="{ 'field-error': errors.submitted_by_email }">
|
||||
<label for="submitted_by_email">
|
||||
Ton email
|
||||
<span class="label-hint">(optionnel — pour le suivi)</span>
|
||||
</label>
|
||||
<input
|
||||
id="submitted_by_email"
|
||||
v-model="form.submitted_by_email"
|
||||
type="email"
|
||||
placeholder="ton@email.fr"
|
||||
autocomplete="email"
|
||||
@blur="validateField('submitted_by_email')"
|
||||
/>
|
||||
<span v-if="errors.submitted_by_email" class="error-msg" role="alert">
|
||||
{{ errors.submitted_by_email }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Erreur globale -->
|
||||
<div v-if="serverError" class="server-error" role="alert">
|
||||
<strong>Erreur :</strong> {{ serverError }}
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="form-actions">
|
||||
<NuxtLink to="/pratiques-regeneratives" class="btn-secondary">Annuler</NuxtLink>
|
||||
<button
|
||||
type="submit"
|
||||
class="btn-primary"
|
||||
:disabled="submitting"
|
||||
>
|
||||
{{ submitting ? 'Envoi en cours...' : 'Proposer la pratique →' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="form-note">
|
||||
Ta proposition sera examinée par Jules avant publication.
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { z } from 'zod'
|
||||
import { CRITERES, TYPES_ENTITE, TYPES_ENTITE_LABELS, EUROPE_CODES, OUTREMER_CODES, PAYS_LABELS } from '~/types/pratique'
|
||||
|
||||
// ── Schéma Zod (côté client — miroir du serveur) ──────────────────────────────
|
||||
|
||||
const PratiqueSubmitSchema = z.object({
|
||||
nom: z.string().min(3, 'Minimum 3 caractères').max(150, 'Maximum 150 caractères').trim(),
|
||||
url: z.string().url('URL invalide (commencer par https://)').optional().or(z.literal('')),
|
||||
description_user: z.string().min(50, 'Minimum 50 caractères').max(500, 'Maximum 500 caractères').trim(),
|
||||
criteres: z
|
||||
.array(z.number().int().min(1).max(8))
|
||||
.min(3, 'Sélectionne au moins 3 critères')
|
||||
.max(8, 'Maximum 8 critères'),
|
||||
pays: z.string().length(2, 'Sélectionne un pays').or(z.literal('AUTRE')),
|
||||
pays_autre: z.string().max(50).optional(),
|
||||
ville: z.string().max(100).optional(),
|
||||
type: z.enum(TYPES_ENTITE, { errorMap: () => ({ message: 'Sélectionne un type d\'entité' }) }),
|
||||
tags: z.array(z.string().max(30)).max(6).optional(),
|
||||
submitted_by_email: z.string().email('Email invalide').optional().or(z.literal('')),
|
||||
})
|
||||
|
||||
// ── État du formulaire ────────────────────────────────────────────────────────
|
||||
|
||||
const form = reactive({
|
||||
nom: '',
|
||||
url: '',
|
||||
description_user: '',
|
||||
criteres: [] as number[],
|
||||
pays: '' as string,
|
||||
pays_autre: '',
|
||||
ville: '',
|
||||
type: '' as typeof TYPES_ENTITE[number] | '',
|
||||
tags: [] as string[],
|
||||
submitted_by_email: '',
|
||||
})
|
||||
|
||||
const tagsInput = ref('')
|
||||
const errors = reactive<Record<string, string>>({})
|
||||
const submitting = ref(false)
|
||||
const success = ref(false)
|
||||
const serverError = ref('')
|
||||
|
||||
// ── Validation champ par champ ────────────────────────────────────────────────
|
||||
|
||||
function validateField(field: string) {
|
||||
const partial = PratiqueSubmitSchema.partial()
|
||||
const result = partial.safeParse({ [field]: (form as any)[field] })
|
||||
if (!result.success) {
|
||||
const fieldErrors = result.error.flatten().fieldErrors
|
||||
errors[field] = fieldErrors[field]?.[0] ?? ''
|
||||
} else {
|
||||
delete errors[field]
|
||||
}
|
||||
}
|
||||
|
||||
function validateAll(): boolean {
|
||||
const result = PratiqueSubmitSchema.safeParse(form)
|
||||
if (!result.success) {
|
||||
const flat = result.error.flatten().fieldErrors
|
||||
Object.assign(errors, Object.fromEntries(
|
||||
Object.entries(flat).map(([k, v]) => [k, v?.[0] ?? ''])
|
||||
))
|
||||
return false
|
||||
}
|
||||
Object.keys(errors).forEach(k => delete errors[k])
|
||||
return true
|
||||
}
|
||||
|
||||
// ── Gestion critères ──────────────────────────────────────────────────────────
|
||||
|
||||
function toggleCritere(id: number) {
|
||||
const idx = form.criteres.indexOf(id)
|
||||
if (idx >= 0) {
|
||||
form.criteres.splice(idx, 1)
|
||||
} else if (form.criteres.length < 8) {
|
||||
form.criteres.push(id)
|
||||
}
|
||||
validateField('criteres')
|
||||
}
|
||||
|
||||
// ── Gestion tags ──────────────────────────────────────────────────────────────
|
||||
|
||||
function parseTags() {
|
||||
const raw = tagsInput.value
|
||||
.split(',')
|
||||
.map(t => t.trim().toLowerCase())
|
||||
.filter(t => t.length > 0 && t.length <= 30)
|
||||
.slice(0, 6)
|
||||
form.tags = raw
|
||||
}
|
||||
|
||||
// ── Soumission ────────────────────────────────────────────────────────────────
|
||||
|
||||
async function submit() {
|
||||
serverError.value = ''
|
||||
parseTags()
|
||||
|
||||
if (!validateAll()) {
|
||||
await nextTick()
|
||||
const firstError = document.querySelector('.field-error')
|
||||
firstError?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
return
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
|
||||
try {
|
||||
await $fetch('/api/submit-pratique', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
nom: form.nom,
|
||||
url: form.url || undefined,
|
||||
description_user: form.description_user,
|
||||
criteres: form.criteres,
|
||||
pays: form.pays,
|
||||
pays_autre: form.pays_autre || undefined,
|
||||
ville: form.ville || undefined,
|
||||
type: form.type,
|
||||
tags: form.tags.length ? form.tags : undefined,
|
||||
submitted_by_email: form.submitted_by_email || undefined,
|
||||
},
|
||||
})
|
||||
|
||||
success.value = true
|
||||
} catch (e: any) {
|
||||
const status = e?.status ?? e?.statusCode
|
||||
if (status === 429) {
|
||||
serverError.value = 'Tu as déjà soumis 3 pratiques aujourd\'hui. Réessaie demain.'
|
||||
} else if (status === 422 && e?.data) {
|
||||
const fieldErrors = e.data
|
||||
Object.entries(fieldErrors).forEach(([k, v]) => {
|
||||
errors[k] = Array.isArray(v) ? v[0] : String(v)
|
||||
})
|
||||
serverError.value = 'Certains champs sont invalides — vérifie les erreurs ci-dessus.'
|
||||
} else {
|
||||
serverError.value = 'Une erreur s\'est produite. Réessaie dans quelques instants.'
|
||||
}
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function reset() {
|
||||
Object.assign(form, {
|
||||
nom: '', url: '', description_user: '', criteres: [],
|
||||
pays: '', pays_autre: '', ville: '', type: '', tags: [], submitted_by_email: '',
|
||||
})
|
||||
tagsInput.value = ''
|
||||
Object.keys(errors).forEach(k => delete errors[k])
|
||||
success.value = false
|
||||
serverError.value = ''
|
||||
}
|
||||
|
||||
// ── Meta ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
useHead({ title: 'Proposer une pratique — AEP' })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* ── Layout ─────────────────────────────────────────────────────────────────── */
|
||||
|
||||
.contribuer-page {
|
||||
min-height: 100vh;
|
||||
background: var(--nav-bg);
|
||||
padding: 1.5rem 1rem 4rem;
|
||||
}
|
||||
|
||||
.contribuer-inner {
|
||||
max-width: 640px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* ── Retour ──────────────────────────────────────────────────────────────────── */
|
||||
|
||||
.back-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--nav-primary-solid);
|
||||
opacity: 0.7;
|
||||
text-decoration: none;
|
||||
margin-bottom: 1.5rem;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.back-link:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* ── En-tête ─────────────────────────────────────────────────────────────────── */
|
||||
|
||||
.contribuer-header {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.contribuer-header h1 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--nav-text);
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
|
||||
.contribuer-subtitle {
|
||||
font-size: 0.9rem;
|
||||
color: var(--nav-text-muted);
|
||||
line-height: 1.5;
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
|
||||
.contribuer-hint {
|
||||
font-size: 0.82rem;
|
||||
color: var(--nav-text-muted);
|
||||
opacity: 0.75;
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ── Succès ──────────────────────────────────────────────────────────────────── */
|
||||
|
||||
.success-block {
|
||||
background: var(--nav-surface);
|
||||
border: 1px solid rgba(26, 34, 56, 0.15);
|
||||
border-radius: 12px;
|
||||
padding: 2rem 1.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.success-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
background: rgba(26, 34, 56, 0.1);
|
||||
color: var(--nav-text);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
margin: 0 auto 1rem;
|
||||
}
|
||||
|
||||
.success-block h2 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
color: var(--nav-text);
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
|
||||
.success-block p {
|
||||
font-size: 0.9rem;
|
||||
color: var(--nav-text-muted);
|
||||
margin: 0 0 0.5rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.success-detail {
|
||||
font-size: 0.85rem !important;
|
||||
}
|
||||
|
||||
/* ── Formulaire ──────────────────────────────────────────────────────────────── */
|
||||
|
||||
.contribuer-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
/* ── Champ générique ─────────────────────────────────────────────────────────── */
|
||||
|
||||
.field-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.field-group label,
|
||||
.field-group legend {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--nav-text);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.field-group fieldset {
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.required {
|
||||
color: #c0392b;
|
||||
}
|
||||
|
||||
.label-hint {
|
||||
font-weight: 400;
|
||||
color: var(--nav-text-muted);
|
||||
font-size: 0.8rem;
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
|
||||
.field-group input[type="text"],
|
||||
.field-group input[type="url"],
|
||||
.field-group input[type="email"],
|
||||
.field-group select,
|
||||
.field-group textarea {
|
||||
width: 100%;
|
||||
padding: 0.625rem 0.875rem;
|
||||
border: 1px solid rgba(26, 34, 56, 0.2);
|
||||
border-radius: 8px;
|
||||
font-size: 0.9rem;
|
||||
color: var(--nav-text);
|
||||
background: var(--nav-surface);
|
||||
font-family: inherit;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.field-group select {
|
||||
cursor: pointer;
|
||||
appearance: auto;
|
||||
}
|
||||
|
||||
.field-group input:focus,
|
||||
.field-group select:focus,
|
||||
.field-group textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--nav-primary-solid);
|
||||
box-shadow: 0 0 0 2px rgba(245, 179, 66, 0.4);
|
||||
}
|
||||
|
||||
.field-group textarea {
|
||||
resize: vertical;
|
||||
min-height: 100px;
|
||||
}
|
||||
|
||||
/* Erreur champ */
|
||||
|
||||
.field-error input,
|
||||
.field-error select,
|
||||
.field-error textarea {
|
||||
border-color: #c0392b !important;
|
||||
}
|
||||
|
||||
.error-msg {
|
||||
font-size: 0.8rem;
|
||||
color: #c0392b;
|
||||
}
|
||||
|
||||
.field-meta {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.char-count {
|
||||
font-size: 0.75rem;
|
||||
color: var(--nav-text-muted);
|
||||
}
|
||||
|
||||
.char-warn {
|
||||
color: #e67e22;
|
||||
}
|
||||
|
||||
/* ── Radio (Type entité) ─────────────────────────────────────────────────────── */
|
||||
|
||||
.radio-group {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.375rem;
|
||||
}
|
||||
|
||||
.radio-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.375rem 0.75rem;
|
||||
border: 1px solid rgba(26, 34, 56, 0.2);
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--nav-text);
|
||||
background: var(--nav-surface);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.radio-label input[type="radio"] {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.radio-label:hover {
|
||||
border-color: var(--nav-primary-solid);
|
||||
background: var(--nav-bg-alt);
|
||||
}
|
||||
|
||||
.radio-label.active {
|
||||
background: var(--nav-primary);
|
||||
border-color: transparent;
|
||||
color: var(--nav-text-on-primary);
|
||||
}
|
||||
|
||||
/* ── Checkboxes (Critères) ───────────────────────────────────────────────────── */
|
||||
|
||||
.checkbox-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.375rem;
|
||||
}
|
||||
|
||||
@media (max-width: 400px) {
|
||||
.checkbox-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.checkbox-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.375rem 0.75rem;
|
||||
border: 1px solid rgba(26, 34, 56, 0.2);
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--nav-text);
|
||||
background: var(--nav-surface);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.checkbox-label input[type="checkbox"] {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.checkbox-label:hover:not(.disabled) {
|
||||
border-color: var(--nav-primary-solid);
|
||||
background: var(--nav-bg-alt);
|
||||
}
|
||||
|
||||
.checkbox-label.active {
|
||||
background: var(--nav-primary);
|
||||
border-color: transparent;
|
||||
color: var(--nav-text-on-primary);
|
||||
}
|
||||
|
||||
.checkbox-label.disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ── Tags preview ────────────────────────────────────────────────────────────── */
|
||||
|
||||
.tags-preview {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.375rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.tag-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.2rem 0.6rem;
|
||||
background: var(--nav-bg-alt);
|
||||
border: 1px solid rgba(26, 34, 56, 0.15);
|
||||
border-radius: 100px;
|
||||
font-size: 0.78rem;
|
||||
color: var(--nav-text-muted);
|
||||
}
|
||||
|
||||
/* ── Erreur serveur ──────────────────────────────────────────────────────────── */
|
||||
|
||||
.server-error {
|
||||
padding: 0.875rem 1rem;
|
||||
background: #fdf0ee;
|
||||
border: 1px solid #e74c3c;
|
||||
border-radius: 8px;
|
||||
font-size: 0.875rem;
|
||||
color: #c0392b;
|
||||
}
|
||||
|
||||
/* ── Actions ──────────────────────────────────────────────────────────────────── */
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
justify-content: flex-end;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: var(--nav-primary);
|
||||
color: var(--nav-text-on-primary);
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
transition: background 0.15s, opacity 0.15s;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: rgba(26, 34, 56, 0.75);
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
padding: 0.75rem 1.25rem;
|
||||
background: transparent;
|
||||
color: var(--nav-text-muted);
|
||||
border: 1px solid rgba(26, 34, 56, 0.2);
|
||||
border-radius: 8px;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
text-decoration: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
transition: border-color 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
border-color: var(--nav-primary-solid);
|
||||
color: var(--nav-text);
|
||||
}
|
||||
|
||||
.form-note {
|
||||
font-size: 0.75rem;
|
||||
color: var(--nav-text-muted);
|
||||
text-align: center;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ── Responsive ──────────────────────────────────────────────────────────────── */
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.contribuer-page {
|
||||
padding: 1rem 0.75rem 3rem;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
flex-direction: column-reverse;
|
||||
}
|
||||
|
||||
.btn-primary,
|
||||
.btn-secondary {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,389 +0,0 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user