feat(aep): carte AEP — push Gitea 2026-04-28

This commit is contained in:
Jules Neny
2026-04-28 14:00:05 +02:00
commit 21c44d8193
86 changed files with 31855 additions and 0 deletions

534
pages/a-propos.vue Normal file
View File

@@ -0,0 +1,534 @@
<template>
<div class="apropos-page">
<div class="apropos-inner">
<!-- Retour -->
<NuxtLink to="/" class="back-link">
Retour à la carte
</NuxtLink>
<!--
SECTION 1 - Mission AEP
-->
<!-- TODO Jules : Écrire le pitch (~100 mots) - qui est AEP, pour qui, pourquoi, quelle promesse -->
<section class="section-mission">
<h1>Architecture d'Écologie Politique</h1>
<p class="mission-text">
L'architecture est l'une des professions les plus complexes qui soit ; elle croise droit, technique, esthétique, économie, social, écologie - tout à la fois, tout simultanément, souvent sans filet. Paradoxalement, c'est aussi l'une des moins structurées sur le plan de l'entraide : peu de transmission horizontale, beaucoup d'isolement, une culture du chacun-pour-soi héritée d'une formation qui prépare à la compétition plus qu'à la coopération. On sort de l'école seul.e. On s'installe seul.e. On réinvente ce que d'autres ont déjà traversé.
</p>
<p class="mission-text">
Cette carte est née de cette frustration - et de cette conviction : les ressources existent, les gens qui ont réussi à sortir la tête de l'eau aussi. L'enjeu, c'est de les documenter, de les rendre accessibles, de les ajuster en temps réel grâce aux retours de la communauté. Pas un catalogue figé ; un commun vivant, au service de ceux et celles qui cherchent à faire évoluer leur pratique vers quelque chose de plus épanouissant, mieux rémunéré, au service de la société - et qui prend soin de la santé, la nôtre et celle des gens pour qui nous construisons.
</p>
</section>
<!-- ══════════════════════════════════════════════════════════
SECTION 2 - Souveraineté technique
══════════════════════════════════════════════════════════ -->
<!-- TODO Jules : Écrire 1-2 phrases d'intro sur la posture technique (pourquoi c'est important pour toi) -->
<section class="section">
<h2>Infrastructure souveraine</h2>
<p class="section-intro">
Ce site tourne sur un VPS - un serveur privé loué en Europe - construit entièrement avec des logiciels open source, quasi gratuits. Pas de services Google, pas d'Amazon, pas de Microsoft dans la chaîne. C'est un choix délibéré : contrôler son infrastructure, c'est contrôler ses données.
</p>
<div class="badges-row">
<div class="badge">
<span class="badge-icon" aria-hidden="true">
<!-- Mistral logo placeholder -->
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 8v4l3 3"/></svg>
</span>
<span class="badge-label">IA souveraine</span>
<span class="badge-detail">Mistral AI, Paris - conforme RGPD ; pas de collecte ni de profiling ; aucun transfert hors UE</span>
</div>
<div class="badge">
<span class="badge-icon" aria-hidden="true">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>
</span>
<span class="badge-label">Hébergé en Europe</span>
<span class="badge-detail">Hetzner, Allemagne</span>
</div>
<div class="badge">
<span class="badge-icon" aria-hidden="true">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
</span>
<span class="badge-label">Zéro cookie US</span>
<span class="badge-detail">Pas de Google Analytics, pas de Meta Pixel, pas de tracker tiers ; on ne sait rien de toi</span>
</div>
</div>
</section>
<!--
SECTION 3 - Gouvernance
-->
<!-- TODO Jules : Écrire qui porte ce projet, le collectif, la posture politique, le lien Liberapay -->
<section class="section">
<h2>Gouvernance</h2>
<p class="section-text">
Architecture d'Écologie Politique est un collectif en train de se monter - ouvert à toustes, à gouvernance partagée. Les décisions se prennent en transparence. Si tu veux participer, contribuer, ou simplement suivre ce qui se construit : tu es la bienvenu·e. Contact : <a href="mailto:contact@trans-former.fr" class="text-link">contact@trans-former.fr</a>
</p>
</section>
<!-- ══════════════════════════════════════════════════════════
SECTION 4 - Transparence Liberapay
══════════════════════════════════════════════════════════ -->
<!-- TODO Jules : Écrire pourquoi Liberapay (pas Patreon, pas Ko-fi), le modèle de financement voulu -->
<section class="section section-liberapay">
<h2>Soutenir le projet</h2>
<p class="section-text">
Ce projet est participatif, libre, open source. Il existe parce que la communauté l'utilise - et il ne peut continuer à exister que si la communauté le soutient.
</p>
<p class="section-text">
Le financer, c'est soutenir la structuration d'un écosystème d'entraide architecturale qui, aujourd'hui, est assez défaillant. Si toi aussi tu as manqué de ressources en chemin, si tu as réinventer seul·e ce que d'autres avaient déjà compris, si tu as cherché un filet et ne l'as pas trouvé - tu sais exactement pourquoi ça compte. Ce que tu mets dans la cagnotte ne va pas dans une boîte ; ça va dans un outil qui permet que la prochaine génération d'architectes ait ce que nous n'avons pas eu : un réseau d'entraide qui fonctionne, au service de pratiques plus épanouissantes, plus politiques, plus écologiques.
</p>
<p class="liberapay-note">
Liberapay est une plateforme open source EU (ASBL belge non-lucrative), 0% de frais.
</p>
<!-- Bouton CTA -->
<a
href="https://liberapay.com/trans-former.fr/donate"
target="_blank"
rel="noopener noreferrer"
class="btn-liberapay"
>
Soutenir sur Liberapay →
</a>
</section>
<!-- ══════════════════════════════════════════════════════════
SECTION 5 - Transparence IA
══════════════════════════════════════════════════════════ -->
<!-- TODO Jules : Écrire la posture sur l'IA - pourquoi ces modèles, pourquoi la transparence, ce que ça signifie politiquement -->
<section id="ia" class="section">
<h2>Transparence IA</h2>
<p class="section-text">
Ce site a été construit grâce à l'IA, par quelqu'un qui n'est pas développeur. C'est une manière de subvertir les outils des Big Tech pour en faire quelque chose de communautaire et de souverain : une infrastructure locale, des données qu'on contrôle, un commun qui n'appartient à personne en particulier.
</p>
<p class="section-text">
L'IA est un outil. La question, c'est au service de quoi. Oui, elle est énergivore - et il faut l'assumer. Mais si c'est au service de l'émancipation, du collectif, de transformer nos infrastructures sociétales, alors on dit oui - en sachant que c'est une étape transitoire. L'infrastructure de l'IA elle-même peut évoluer : devenir plus locale, plus sobre, plus souveraine. C'est dans cette direction qu'on regarde.
</p>
<div class="ia-grid">
<div class="ia-card">
<div class="ia-card-label">Enrichissement des fiches</div>
<div class="ia-card-model">Mistral Nemo</div>
<div class="ia-card-cost">~0,00003 /fiche</div>
</div>
<div class="ia-card">
<div class="ia-card-label">Chatbot de recherche</div>
<div class="ia-card-model">Mistral Small</div>
<div class="ia-card-cost">~0,003 /requête</div>
</div>
</div>
<ul class="ia-details">
<li>10 / mois investis au départ par le collectif - vos dons constituent la réserve pour faire vivre le projet et financer ses recherches</li>
<li>Coût visible en direct dans le bandeau bas de la carte</li>
</ul>
</section>
<!--
SECTION 6 - Contribuer
-->
<!-- TODO Jules : Écrire l'invitation à contribuer - ton, posture, ce qu'on attend des contributeurs -->
<section class="section section-contribuer">
<h2>Contribuer</h2>
<p class="section-text">
Il n'y a pas de barre d'entrée ici. Une correction, une ressource à signaler, un retour sur ta propre expérience - c'est déjà participer à quelque chose qui grandit par accumulation de petits gestes.
</p>
<div class="contribuer-ctas">
<NuxtLink to="/contribuer" class="btn-primary">
Proposer une fiche →
</NuxtLink>
<a
href="https://trans-former.fr/"
target="_blank"
rel="noopener noreferrer"
class="btn-secondary"
>
Blog AEP
</a>
<a
href="https://www.instagram.com/aep.politique/"
target="_blank"
rel="noopener noreferrer"
class="btn-secondary"
>
Instagram
</a>
<a
href="https://liberapay.com/trans-former.fr/donate"
target="_blank"
rel="noopener noreferrer"
class="btn-secondary"
>
Soutenir
</a>
<a
href="mailto:contact@trans-former.fr"
class="btn-secondary"
>
Contact
</a>
</div>
</section>
<!-- ══════════════════════════════════════════════════════════
SECTION 7 - Open Source
══════════════════════════════════════════════════════════ -->
<section class="section section-opensource">
<h2>Code source ouvert</h2>
<p class="section-text">
Cette carte est un commun technique autant qu'un commun de ressources. Le code source est libre tu peux le forker, l'adapter, le déployer pour ta propre cartographie d'entraide, ton territoire, ta filière.
</p>
<p class="section-text">
Une liste de ressources locales pour les artisans ? Un réseau d'entraide pour les soignants indépendants ? La structure est la même. L'IA d'enrichissement est la même. Il suffit de changer les données.
</p>
<a
href="https://git.trans-former.fr/jules/nav-carte"
target="_blank"
rel="noopener noreferrer"
class="btn-github"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0 0 24 12c0-6.63-5.37-12-12-12z"/></svg>
Code source — git.trans-former.fr →
</a>
</section>
</div>
</div>
</template>
<script setup lang="ts">
useHead({ title: 'À propos - AEP' })
</script>
<style scoped>
/* ── Layout ─────────────────────────────────────────────────────────────────── */
.apropos-page {
min-height: 100vh;
background: var(--nav-bg);
padding: 1.5rem 1rem 5rem;
}
.apropos-inner {
max-width: 720px;
margin: 0 auto;
}
/* ── Retour ──────────────────────────────────────────────────────────────────── */
.back-link {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-size: 0.875rem;
color: var(--nav-primary-solid);
opacity: 0.7;
text-decoration: none;
margin-bottom: 2rem;
transition: opacity 0.15s;
}
.back-link:hover {
opacity: 1;
}
/* ── Sections ────────────────────────────────────────────────────────────────── */
.section-mission,
.section {
margin-bottom: 3rem;
}
.section-mission h1 {
font-size: 1.75rem;
font-weight: 700;
color: var(--nav-text);
margin: 0 0 1rem;
line-height: 1.2;
}
.section h2 {
font-size: 1.2rem;
font-weight: 700;
color: var(--nav-text);
margin: 0 0 0.75rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--nav-bg-alt);
}
.mission-text,
.section-intro,
.section-text {
font-size: 0.95rem;
color: var(--nav-text);
line-height: 1.65;
margin: 0 0 1rem;
}
.section-intro {
color: var(--nav-text-muted);
margin-bottom: 1.25rem;
}
.text-link {
color: var(--nav-primary-solid);
text-decoration: underline;
text-underline-offset: 2px;
}
.text-link:hover {
opacity: 0.75;
}
/* ── Badges souveraineté ──────────────────────────────────────────────────────── */
.badges-row {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
}
.badge {
display: inline-flex;
align-items: flex-start;
gap: 0.5rem;
padding: 0.625rem 0.875rem;
background: var(--nav-bg-alt);
border-radius: 9999px;
flex-direction: column;
min-width: 160px;
flex: 1;
}
@media (min-width: 560px) {
.badge {
flex-direction: row;
align-items: center;
flex: none;
min-width: auto;
}
}
.badge-icon {
color: var(--nav-text-muted);
flex-shrink: 0;
display: flex;
align-items: center;
}
.badge-label {
font-size: 0.875rem;
font-weight: 600;
color: var(--nav-text);
white-space: nowrap;
}
.badge-detail {
font-size: 0.775rem;
color: var(--nav-text-muted);
white-space: nowrap;
}
@media (max-width: 559px) {
.badge {
border-radius: 12px;
padding: 0.75rem 1rem;
}
.badge-label {
font-size: 0.9rem;
}
}
/* ── Liberapay ───────────────────────────────────────────────────────────────── */
.liberapay-note {
font-size: 0.8rem;
color: var(--nav-text-muted);
margin: 0 0 1rem;
font-style: italic;
}
.btn-liberapay {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.625rem 1.25rem;
background: var(--nav-primary);
color: var(--nav-text-on-primary);
border-radius: 8px;
font-size: 0.875rem;
font-weight: 600;
text-decoration: none;
transition: background 0.15s;
}
.btn-liberapay:hover {
background: rgba(26, 34, 56, 0.75);
}
/* ── IA grid ─────────────────────────────────────────────────────────────────── */
.ia-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.75rem;
margin-bottom: 1rem;
}
@media (max-width: 480px) {
.ia-grid {
grid-template-columns: 1fr;
}
}
.ia-card {
background: var(--nav-surface);
border: 1px solid rgba(26, 34, 56, 0.1);
border-radius: 10px;
padding: 1rem 1.125rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.ia-card-label {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--nav-text-muted);
font-weight: 600;
}
.ia-card-model {
font-size: 1rem;
font-weight: 700;
color: var(--nav-text);
}
.ia-card-cost {
font-size: 0.825rem;
color: var(--nav-text-muted);
font-variant-numeric: tabular-nums;
}
.ia-details {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.ia-details li {
font-size: 0.875rem;
color: var(--nav-text-muted);
padding-left: 1rem;
position: relative;
line-height: 1.5;
}
.ia-details li::before {
content: '';
position: absolute;
left: 0;
color: var(--nav-text-muted);
opacity: 0.5;
}
/* ── CTAs contribuer ─────────────────────────────────────────────────────────── */
.contribuer-ctas {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
margin-top: 1rem;
}
.btn-primary {
display: inline-flex;
align-items: center;
padding: 0.75rem 1.5rem;
background: var(--nav-accent);
color: var(--nav-text);
border-radius: 8px;
font-size: 0.9rem;
font-weight: 600;
text-decoration: none;
transition: opacity 0.15s;
}
.btn-primary:hover {
opacity: 0.85;
}
.btn-secondary {
display: inline-flex;
align-items: center;
padding: 0.75rem 1.25rem;
background: transparent;
color: var(--nav-text-muted);
border: 1px solid rgba(26, 34, 56, 0.2);
border-radius: 8px;
font-size: 0.9rem;
text-decoration: none;
transition: border-color 0.15s, color 0.15s;
}
.btn-secondary:hover {
border-color: var(--nav-primary-solid);
color: var(--nav-text);
}
/* ── GitHub open source ──────────────────────────────────────────────────────── */
.section-opensource {
border-top: 1px solid var(--nav-bg-alt);
padding-top: 2rem;
}
.btn-github {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.625rem 1.25rem;
background: var(--nav-surface);
border: 1px solid rgba(26, 34, 56, 0.2);
border-radius: 8px;
font-size: 0.875rem;
font-weight: 600;
color: var(--nav-text);
text-decoration: none;
transition: border-color 0.15s, background 0.15s;
margin-top: 0.5rem;
}
.btn-github:hover {
border-color: var(--nav-primary-solid);
background: var(--nav-bg-alt);
}
/* ── Responsive général ──────────────────────────────────────────────────────── */
@media (max-width: 480px) {
.apropos-page {
padding: 1rem 0.75rem 4rem;
}
.section-mission h1 {
font-size: 1.4rem;
}
.contribuer-ctas {
flex-direction: column;
}
.btn-primary,
.btn-secondary {
width: 100%;
justify-content: center;
}
}
</style>

View File

@@ -0,0 +1,477 @@
<template>
<div class="apropos-page">
<div class="apropos-inner">
<!-- Retour -->
<NuxtLink to="/" class="back-link">
← Retour à la carte
</NuxtLink>
<!-- ══════════════════════════════════════════════════════════
SECTION 1 — Mission AEP
══════════════════════════════════════════════════════════ -->
<!-- TODO Jules : Écrire le pitch (~100 mots) — qui est AEP, pour qui, pourquoi, quelle promesse -->
<section class="section-mission">
<h1>Architecture d'Écologie Politique</h1>
<p class="mission-text">
L'architecture est l'une des professions les plus complexes qui soit ; elle croise droit, technique, esthétique, économie, social, écologie - tout à la fois, tout simultanément, souvent sans filet. Paradoxalement, c'est aussi l'une des moins structurées sur le plan de l'entraide : peu de transmission horizontale, beaucoup d'isolement, une culture du chacun-pour-soi héritée d'une formation qui prépare à la compétition plus qu'à la coopération. On sort de l'école seul.e. On s'installe seul.e. On réinvente ce que d'autres ont déjà traversé.
</p>
<p class="mission-text">
Cette carte est née de cette frustration - et de cette conviction : les ressources existent, les gens qui ont réussi à sortir la tête de l'eau aussi. L'enjeu, c'est de les documenter, de les rendre accessibles, de les ajuster en temps réel grâce aux retours de la communauté. Pas un catalogue figé ; un commun vivant, au service de ceux et celles qui cherchent à faire évoluer leur pratique vers quelque chose de plus épanouissant, mieux rémunéré, au service de la société - et qui prend soin de la santé, la nôtre et celle des gens pour qui nous construisons.
</p>
</section>
<!-- ══════════════════════════════════════════════════════════
SECTION 2 — Souveraineté technique
══════════════════════════════════════════════════════════ -->
<!-- TODO Jules : Écrire 1-2 phrases d'intro sur la posture technique (pourquoi c'est important pour toi) -->
<section class="section">
<h2>Souveraineté technique</h2>
<p class="section-intro">
Ce site a été construit en une journée par quelqu'un qui n'est pas développeur. C'est délibéré : l'IA permet aujourd'hui de structurer des outils d'émancipation collective sans expertise technique préalable, à condition de choisir des briques sobres et souveraines. L'architecture est simple ; le chatbot tourne sur un modèle léger, peu gourmand en énergie. Pas de dépendance aux infrastructures américaines, pas de collecte de données ni de logique de monétisation.
</p>
<div class="badges-row">
<div class="badge">
<span class="badge-icon" aria-hidden="true">
<!-- Mistral logo placeholder -->
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 8v4l3 3"/></svg>
</span>
<span class="badge-label">IA souveraine</span>
<span class="badge-detail">Mistral AI, Paris</span>
</div>
<div class="badge">
<span class="badge-icon" aria-hidden="true">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>
</span>
<span class="badge-label">Hébergé en Europe</span>
<span class="badge-detail">Hetzner, Allemagne</span>
</div>
<div class="badge">
<span class="badge-icon" aria-hidden="true">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
</span>
<span class="badge-label">Zéro cookie US</span>
<span class="badge-detail">Pas de Google Analytics, pas de Meta Pixel, pas de tracker tiers</span>
</div>
</div>
</section>
<!-- ══════════════════════════════════════════════════════════
SECTION 3 — Gouvernance
══════════════════════════════════════════════════════════ -->
<!-- TODO Jules : Écrire qui porte ce projet, le collectif, la posture politique, le lien Liberapay -->
<section class="section">
<h2>Gouvernance</h2>
<p class="section-text">
Il m'a fallu cinq ans après les études pour sortir la tête de l'eau et commencer à avoir une pratique un peu plus alignée avec qui je suis. Cinq ans de chemin solitaire, à chercher des ressources qui n'existaient pas - ou qui existaient quelque part, mais que personne ne partageait. Ce qui m'a le plus sidéré, c'est le silence des anciens : pas par malveillance, mais parce qu'eux aussi étaient dans leur galère, la tête baissée, à serrer les dents. Atomisé·es, chacun·e dans son monde. Cette carte est née de l'envie que ça change.
</p>
<p class="section-text">
Architecture d'Écologie Politique est un collectif en train de se monter - ouvert à toustes, à gouvernance horizontale, sans hiérarchie de droit. Les décisions se prennent en transparence. Si tu veux participer, contribuer, ou simplement suivre ce qui se construit : tu es la bienvenu·e. Contact : <a href="mailto:contact@trans-former.fr" class="text-link">contact@trans-former.fr</a>
</p>
</section>
<!-- ══════════════════════════════════════════════════════════
SECTION 4 — Transparence Liberapay
══════════════════════════════════════════════════════════ -->
<!-- TODO Jules : Écrire pourquoi Liberapay (pas Patreon, pas Ko-fi), le modèle de financement voulu -->
<section class="section section-liberapay">
<h2>Soutenir le projet</h2>
<p class="section-text">
Ce projet est participatif, libre, open source. Il existe parce que la communauté l'utilise - et il ne peut continuer à exister que si la communauté le soutient.
</p>
<p class="section-text">
Le financer, c'est soutenir la structuration d'un écosystème d'entraide architecturale qui, aujourd'hui, est assez défaillant. Si toi aussi tu as manqué de ressources en chemin, si tu as dû réinventer seul·e ce que d'autres avaient déjà compris, si tu as cherché un filet et ne l'as pas trouvé - tu sais exactement pourquoi ça compte. Ce que tu mets dans la cagnotte ne va pas dans une boîte ; ça va dans un outil qui permet que la prochaine génération d'architectes ait ce que nous n'avons pas eu : un réseau d'entraide qui fonctionne, au service de pratiques plus épanouissantes, plus politiques, plus écologiques.
</p>
<p class="liberapay-note">
Liberapay est une plateforme open source EU (ASBL belge non-lucrative), 0% de frais.
</p>
<!-- Bouton CTA -->
<a
href="https://liberapay.com/trans-former.fr/donate"
target="_blank"
rel="noopener noreferrer"
class="btn-liberapay"
>
Soutenir sur Liberapay →
</a>
</section>
<!-- ══════════════════════════════════════════════════════════
SECTION 5 — Transparence IA
══════════════════════════════════════════════════════════ -->
<!-- TODO Jules : Écrire la posture sur l'IA — pourquoi ces modèles, pourquoi la transparence, ce que ça signifie politiquement -->
<section id="ia" class="section">
<h2>Transparence IA</h2>
<p class="section-text">
Les Big Tech cachent leurs coûts parce qu'ils n'ont pas intérêt à ce que tu les voies. Afficher ce qu'on dépense ici, c'est un acte politique : sobriété choisie, modèles européens, rapport assumé aux outils — pas par vertu, mais par cohérence avec ce qu'on construit.
</p>
<div class="ia-grid">
<div class="ia-card">
<div class="ia-card-label">Enrichissement des fiches</div>
<div class="ia-card-model">Mistral Nemo</div>
<div class="ia-card-cost">~0,00003 €/fiche</div>
</div>
<div class="ia-card">
<div class="ia-card-label">Chatbot de recherche</div>
<div class="ia-card-model">Mistral Small</div>
<div class="ia-card-cost">~0,003 €/requête</div>
</div>
</div>
<ul class="ia-details">
<li>Budget plafonné à 20 € / mois — coupure automatique si dépassement</li>
<li>Coût visible en direct dans le bandeau bas de la carte</li>
<li>Aucun modèle US, aucune donnée transmise hors UE</li>
</ul>
</section>
<!-- ══════════════════════════════════════════════════════════
SECTION 6 — Contribuer
══════════════════════════════════════════════════════════ -->
<!-- TODO Jules : Écrire l'invitation à contribuer — ton, posture, ce qu'on attend des contributeurs -->
<section class="section section-contribuer">
<h2>Contribuer</h2>
<p class="section-text">
Il n'y a pas de barre d'entrée ici. Une correction, une ressource à signaler, un retour sur ta propre expérience — c'est déjà participer à quelque chose qui grandit par accumulation de petits gestes.
</p>
<div class="contribuer-ctas">
<NuxtLink to="/contribuer" class="btn-primary">
Proposer une fiche →
</NuxtLink>
<a
href="https://liberapay.com/trans-former.fr/donate"
target="_blank"
rel="noopener noreferrer"
class="btn-secondary"
>
Soutenir financièrement
</a>
<a
href="https://mail.trans-former.fr/subscription/form"
target="_blank"
rel="noopener noreferrer"
class="btn-secondary"
>
S'inscrire à l'infolettre
</a>
<a
href="mailto:contact@trans-former.fr"
class="btn-secondary"
>
Nous contacter
</a>
</div>
</section>
</div>
</div>
</template>
<script setup lang="ts">
useHead({ title: 'À propos — AEP' })
</script>
<style scoped>
/* ── Layout ─────────────────────────────────────────────────────────────────── */
.apropos-page {
min-height: 100vh;
background: var(--nav-bg);
padding: 1.5rem 1rem 5rem;
}
.apropos-inner {
max-width: 720px;
margin: 0 auto;
}
/* ── Retour ──────────────────────────────────────────────────────────────────── */
.back-link {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-size: 0.875rem;
color: var(--nav-primary-solid);
opacity: 0.7;
text-decoration: none;
margin-bottom: 2rem;
transition: opacity 0.15s;
}
.back-link:hover {
opacity: 1;
}
/* ── Sections ────────────────────────────────────────────────────────────────── */
.section-mission,
.section {
margin-bottom: 3rem;
}
.section-mission h1 {
font-size: 1.75rem;
font-weight: 700;
color: var(--nav-text);
margin: 0 0 1rem;
line-height: 1.2;
}
.section h2 {
font-size: 1.2rem;
font-weight: 700;
color: var(--nav-text);
margin: 0 0 0.75rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--nav-bg-alt);
}
.mission-text,
.section-intro,
.section-text {
font-size: 0.95rem;
color: var(--nav-text);
line-height: 1.65;
margin: 0 0 1rem;
}
.section-intro {
color: var(--nav-text-muted);
margin-bottom: 1.25rem;
}
.text-link {
color: var(--nav-primary-solid);
text-decoration: underline;
text-underline-offset: 2px;
}
.text-link:hover {
opacity: 0.75;
}
/* ── Badges souveraineté ──────────────────────────────────────────────────────── */
.badges-row {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
}
.badge {
display: inline-flex;
align-items: flex-start;
gap: 0.5rem;
padding: 0.625rem 0.875rem;
background: var(--nav-bg-alt);
border-radius: 9999px;
flex-direction: column;
min-width: 160px;
flex: 1;
}
@media (min-width: 560px) {
.badge {
flex-direction: row;
align-items: center;
flex: none;
min-width: auto;
}
}
.badge-icon {
color: var(--nav-text-muted);
flex-shrink: 0;
display: flex;
align-items: center;
}
.badge-label {
font-size: 0.875rem;
font-weight: 600;
color: var(--nav-text);
white-space: nowrap;
}
.badge-detail {
font-size: 0.775rem;
color: var(--nav-text-muted);
white-space: nowrap;
}
@media (max-width: 559px) {
.badge {
border-radius: 12px;
padding: 0.75rem 1rem;
}
.badge-label {
font-size: 0.9rem;
}
}
/* ── Liberapay ───────────────────────────────────────────────────────────────── */
.liberapay-note {
font-size: 0.8rem;
color: var(--nav-text-muted);
margin: 0 0 1rem;
font-style: italic;
}
.btn-liberapay {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.625rem 1.25rem;
background: var(--nav-primary);
color: var(--nav-text-on-primary);
border-radius: 8px;
font-size: 0.875rem;
font-weight: 600;
text-decoration: none;
transition: background 0.15s;
}
.btn-liberapay:hover {
background: rgba(26, 34, 56, 0.75);
}
/* ── IA grid ─────────────────────────────────────────────────────────────────── */
.ia-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.75rem;
margin-bottom: 1rem;
}
@media (max-width: 480px) {
.ia-grid {
grid-template-columns: 1fr;
}
}
.ia-card {
background: var(--nav-surface);
border: 1px solid rgba(26, 34, 56, 0.1);
border-radius: 10px;
padding: 1rem 1.125rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.ia-card-label {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--nav-text-muted);
font-weight: 600;
}
.ia-card-model {
font-size: 1rem;
font-weight: 700;
color: var(--nav-text);
}
.ia-card-cost {
font-size: 0.825rem;
color: var(--nav-text-muted);
font-variant-numeric: tabular-nums;
}
.ia-details {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.ia-details li {
font-size: 0.875rem;
color: var(--nav-text-muted);
padding-left: 1rem;
position: relative;
line-height: 1.5;
}
.ia-details li::before {
content: '—';
position: absolute;
left: 0;
color: var(--nav-text-muted);
opacity: 0.5;
}
/* ── CTAs contribuer ─────────────────────────────────────────────────────────── */
.contribuer-ctas {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
margin-top: 1rem;
}
.btn-primary {
display: inline-flex;
align-items: center;
padding: 0.75rem 1.5rem;
background: var(--nav-accent);
color: var(--nav-text);
border-radius: 8px;
font-size: 0.9rem;
font-weight: 600;
text-decoration: none;
transition: opacity 0.15s;
}
.btn-primary:hover {
opacity: 0.85;
}
.btn-secondary {
display: inline-flex;
align-items: center;
padding: 0.75rem 1.25rem;
background: transparent;
color: var(--nav-text-muted);
border: 1px solid rgba(26, 34, 56, 0.2);
border-radius: 8px;
font-size: 0.9rem;
text-decoration: none;
transition: border-color 0.15s, color 0.15s;
}
.btn-secondary:hover {
border-color: var(--nav-primary-solid);
color: var(--nav-text);
}
/* ── Responsive général ──────────────────────────────────────────────────────── */
@media (max-width: 480px) {
.apropos-page {
padding: 1rem 0.75rem 4rem;
}
.section-mission h1 {
font-size: 1.4rem;
}
.contribuer-ctas {
flex-direction: column;
}
.btn-primary,
.btn-secondary {
width: 100%;
justify-content: center;
}
}
</style>

View File

@@ -0,0 +1,534 @@
<template>
<div class="apropos-page">
<div class="apropos-inner">
<!-- Retour -->
<NuxtLink to="/" class="back-link">
← Retour à la carte
</NuxtLink>
<!-- ══════════════════════════════════════════════════════════
SECTION 1 - Mission AEP
══════════════════════════════════════════════════════════ -->
<!-- TODO Jules : Écrire le pitch (~100 mots) - qui est AEP, pour qui, pourquoi, quelle promesse -->
<section class="section-mission">
<h1>Architecture d'Écologie Politique</h1>
<p class="mission-text">
L'architecture est l'une des professions les plus complexes qui soit ; elle croise droit, technique, esthétique, économie, social, écologie - tout à la fois, tout simultanément, souvent sans filet. Paradoxalement, c'est aussi l'une des moins structurées sur le plan de l'entraide : peu de transmission horizontale, beaucoup d'isolement, une culture du chacun-pour-soi héritée d'une formation qui prépare à la compétition plus qu'à la coopération. On sort de l'école seul.e. On s'installe seul.e. On réinvente ce que d'autres ont déjà traversé.
</p>
<p class="mission-text">
Cette carte est née de cette frustration - et de cette conviction : les ressources existent, les gens qui ont réussi à sortir la tête de l'eau aussi. L'enjeu, c'est de les documenter, de les rendre accessibles, de les ajuster en temps réel grâce aux retours de la communauté. Pas un catalogue figé ; un commun vivant, au service de ceux et celles qui cherchent à faire évoluer leur pratique vers quelque chose de plus épanouissant, mieux rémunéré, au service de la société - et qui prend soin de la santé, la nôtre et celle des gens pour qui nous construisons.
</p>
</section>
<!-- ══════════════════════════════════════════════════════════
SECTION 2 - Souveraineté technique
══════════════════════════════════════════════════════════ -->
<!-- TODO Jules : Écrire 1-2 phrases d'intro sur la posture technique (pourquoi c'est important pour toi) -->
<section class="section">
<h2>Infrastructure souveraine</h2>
<p class="section-intro">
Ce site tourne sur un VPS - un serveur privé loué en Europe - construit entièrement avec des logiciels open source, quasi gratuits. Pas de services Google, pas d'Amazon, pas de Microsoft dans la chaîne. C'est un choix délibéré : contrôler son infrastructure, c'est contrôler ses données.
</p>
<div class="badges-row">
<div class="badge">
<span class="badge-icon" aria-hidden="true">
<!-- Mistral logo placeholder -->
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 8v4l3 3"/></svg>
</span>
<span class="badge-label">IA souveraine</span>
<span class="badge-detail">Mistral AI, Paris - conforme RGPD ; pas de collecte ni de profiling ; aucun transfert hors UE</span>
</div>
<div class="badge">
<span class="badge-icon" aria-hidden="true">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>
</span>
<span class="badge-label">Hébergé en Europe</span>
<span class="badge-detail">Hetzner, Allemagne</span>
</div>
<div class="badge">
<span class="badge-icon" aria-hidden="true">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
</span>
<span class="badge-label">Zéro cookie US</span>
<span class="badge-detail">Pas de Google Analytics, pas de Meta Pixel, pas de tracker tiers ; on ne sait rien de toi</span>
</div>
</div>
</section>
<!-- ══════════════════════════════════════════════════════════
SECTION 3 - Gouvernance
══════════════════════════════════════════════════════════ -->
<!-- TODO Jules : Écrire qui porte ce projet, le collectif, la posture politique, le lien Liberapay -->
<section class="section">
<h2>Gouvernance</h2>
<p class="section-text">
Architecture d'Écologie Politique est un collectif en train de se monter - ouvert à toustes, à gouvernance partagée. Les décisions se prennent en transparence. Si tu veux participer, contribuer, ou simplement suivre ce qui se construit : tu es la bienvenu·e. Contact : <a href="mailto:contact@trans-former.fr" class="text-link">contact@trans-former.fr</a>
</p>
</section>
<!-- ══════════════════════════════════════════════════════════
SECTION 4 - Transparence Liberapay
══════════════════════════════════════════════════════════ -->
<!-- TODO Jules : Écrire pourquoi Liberapay (pas Patreon, pas Ko-fi), le modèle de financement voulu -->
<section class="section section-liberapay">
<h2>Soutenir le projet</h2>
<p class="section-text">
Ce projet est participatif, libre, open source. Il existe parce que la communauté l'utilise - et il ne peut continuer à exister que si la communauté le soutient.
</p>
<p class="section-text">
Le financer, c'est soutenir la structuration d'un écosystème d'entraide architecturale qui, aujourd'hui, est assez défaillant. Si toi aussi tu as manqué de ressources en chemin, si tu as dû réinventer seul·e ce que d'autres avaient déjà compris, si tu as cherché un filet et ne l'as pas trouvé - tu sais exactement pourquoi ça compte. Ce que tu mets dans la cagnotte ne va pas dans une boîte ; ça va dans un outil qui permet que la prochaine génération d'architectes ait ce que nous n'avons pas eu : un réseau d'entraide qui fonctionne, au service de pratiques plus épanouissantes, plus politiques, plus écologiques.
</p>
<p class="liberapay-note">
Liberapay est une plateforme open source EU (ASBL belge non-lucrative), 0% de frais.
</p>
<!-- Bouton CTA -->
<a
href="https://liberapay.com/trans-former.fr/donate"
target="_blank"
rel="noopener noreferrer"
class="btn-liberapay"
>
Soutenir sur Liberapay →
</a>
</section>
<!-- ══════════════════════════════════════════════════════════
SECTION 5 - Transparence IA
══════════════════════════════════════════════════════════ -->
<!-- TODO Jules : Écrire la posture sur l'IA - pourquoi ces modèles, pourquoi la transparence, ce que ça signifie politiquement -->
<section id="ia" class="section">
<h2>Transparence IA</h2>
<p class="section-text">
Ce site a été construit grâce à l'IA, par quelqu'un qui n'est pas développeur. C'est une manière de subvertir les outils des Big Tech pour en faire quelque chose de communautaire et de souverain : une infrastructure locale, des données qu'on contrôle, un commun qui n'appartient à personne en particulier.
</p>
<p class="section-text">
L'IA est un outil. La question, c'est au service de quoi. Oui, elle est énergivore - et il faut l'assumer. Mais si c'est au service de l'émancipation, du collectif, de transformer nos infrastructures sociétales, alors on dit oui - en sachant que c'est une étape transitoire. L'infrastructure de l'IA elle-même peut évoluer : devenir plus locale, plus sobre, plus souveraine. C'est dans cette direction qu'on regarde.
</p>
<div class="ia-grid">
<div class="ia-card">
<div class="ia-card-label">Enrichissement des fiches</div>
<div class="ia-card-model">Mistral Nemo</div>
<div class="ia-card-cost">~0,00003 €/fiche</div>
</div>
<div class="ia-card">
<div class="ia-card-label">Chatbot de recherche</div>
<div class="ia-card-model">Mistral Small</div>
<div class="ia-card-cost">~0,003 €/requête</div>
</div>
</div>
<ul class="ia-details">
<li>10 € / mois investis au départ par le collectif - vos dons constituent la réserve pour faire vivre le projet et financer ses recherches</li>
<li>Coût visible en direct dans le bandeau bas de la carte</li>
</ul>
</section>
<!-- ══════════════════════════════════════════════════════════
SECTION 6 - Contribuer
══════════════════════════════════════════════════════════ -->
<!-- TODO Jules : Écrire l'invitation à contribuer - ton, posture, ce qu'on attend des contributeurs -->
<section class="section section-contribuer">
<h2>Contribuer</h2>
<p class="section-text">
Il n'y a pas de barre d'entrée ici. Une correction, une ressource à signaler, un retour sur ta propre expérience - c'est déjà participer à quelque chose qui grandit par accumulation de petits gestes.
</p>
<div class="contribuer-ctas">
<NuxtLink to="/contribuer" class="btn-primary">
Proposer une fiche →
</NuxtLink>
<a
href="https://trans-former.fr/"
target="_blank"
rel="noopener noreferrer"
class="btn-secondary"
>
Blog AEP
</a>
<a
href="https://www.instagram.com/aep.politique/"
target="_blank"
rel="noopener noreferrer"
class="btn-secondary"
>
Instagram
</a>
<a
href="https://liberapay.com/trans-former.fr/donate"
target="_blank"
rel="noopener noreferrer"
class="btn-secondary"
>
Soutenir
</a>
<a
href="mailto:contact@trans-former.fr"
class="btn-secondary"
>
Contact
</a>
</div>
</section>
<!-- ══════════════════════════════════════════════════════════
SECTION 7 - Open Source
══════════════════════════════════════════════════════════ -->
<section class="section section-opensource">
<h2>Code source ouvert</h2>
<p class="section-text">
Cette carte est un commun technique autant qu'un commun de ressources. Le code source est libre — tu peux le forker, l'adapter, le déployer pour ta propre cartographie d'entraide, ton territoire, ta filière.
</p>
<p class="section-text">
Une liste de ressources locales pour les artisans ? Un réseau d'entraide pour les soignants indépendants ? La structure est la même. L'IA d'enrichissement est la même. Il suffit de changer les données.
</p>
<a
href="https://git.trans-former.fr/jules/nav-carte"
target="_blank"
rel="noopener noreferrer"
class="btn-github"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0 0 24 12c0-6.63-5.37-12-12-12z"/></svg>
Code source — git.trans-former.fr →
</a>
</section>
</div>
</div>
</template>
<script setup lang="ts">
useHead({ title: 'À propos - AEP' })
</script>
<style scoped>
/* ── Layout ─────────────────────────────────────────────────────────────────── */
.apropos-page {
min-height: 100vh;
background: var(--nav-bg);
padding: 1.5rem 1rem 5rem;
}
.apropos-inner {
max-width: 720px;
margin: 0 auto;
}
/* ── Retour ──────────────────────────────────────────────────────────────────── */
.back-link {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-size: 0.875rem;
color: var(--nav-primary-solid);
opacity: 0.7;
text-decoration: none;
margin-bottom: 2rem;
transition: opacity 0.15s;
}
.back-link:hover {
opacity: 1;
}
/* ── Sections ────────────────────────────────────────────────────────────────── */
.section-mission,
.section {
margin-bottom: 3rem;
}
.section-mission h1 {
font-size: 1.75rem;
font-weight: 700;
color: var(--nav-text);
margin: 0 0 1rem;
line-height: 1.2;
}
.section h2 {
font-size: 1.2rem;
font-weight: 700;
color: var(--nav-text);
margin: 0 0 0.75rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--nav-bg-alt);
}
.mission-text,
.section-intro,
.section-text {
font-size: 0.95rem;
color: var(--nav-text);
line-height: 1.65;
margin: 0 0 1rem;
}
.section-intro {
color: var(--nav-text-muted);
margin-bottom: 1.25rem;
}
.text-link {
color: var(--nav-primary-solid);
text-decoration: underline;
text-underline-offset: 2px;
}
.text-link:hover {
opacity: 0.75;
}
/* ── Badges souveraineté ──────────────────────────────────────────────────────── */
.badges-row {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
}
.badge {
display: inline-flex;
align-items: flex-start;
gap: 0.5rem;
padding: 0.625rem 0.875rem;
background: var(--nav-bg-alt);
border-radius: 9999px;
flex-direction: column;
min-width: 160px;
flex: 1;
}
@media (min-width: 560px) {
.badge {
flex-direction: row;
align-items: center;
flex: none;
min-width: auto;
}
}
.badge-icon {
color: var(--nav-text-muted);
flex-shrink: 0;
display: flex;
align-items: center;
}
.badge-label {
font-size: 0.875rem;
font-weight: 600;
color: var(--nav-text);
white-space: nowrap;
}
.badge-detail {
font-size: 0.775rem;
color: var(--nav-text-muted);
white-space: nowrap;
}
@media (max-width: 559px) {
.badge {
border-radius: 12px;
padding: 0.75rem 1rem;
}
.badge-label {
font-size: 0.9rem;
}
}
/* ── Liberapay ───────────────────────────────────────────────────────────────── */
.liberapay-note {
font-size: 0.8rem;
color: var(--nav-text-muted);
margin: 0 0 1rem;
font-style: italic;
}
.btn-liberapay {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.625rem 1.25rem;
background: var(--nav-primary);
color: var(--nav-text-on-primary);
border-radius: 8px;
font-size: 0.875rem;
font-weight: 600;
text-decoration: none;
transition: background 0.15s;
}
.btn-liberapay:hover {
background: rgba(26, 34, 56, 0.75);
}
/* ── IA grid ─────────────────────────────────────────────────────────────────── */
.ia-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.75rem;
margin-bottom: 1rem;
}
@media (max-width: 480px) {
.ia-grid {
grid-template-columns: 1fr;
}
}
.ia-card {
background: var(--nav-surface);
border: 1px solid rgba(26, 34, 56, 0.1);
border-radius: 10px;
padding: 1rem 1.125rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.ia-card-label {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--nav-text-muted);
font-weight: 600;
}
.ia-card-model {
font-size: 1rem;
font-weight: 700;
color: var(--nav-text);
}
.ia-card-cost {
font-size: 0.825rem;
color: var(--nav-text-muted);
font-variant-numeric: tabular-nums;
}
.ia-details {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.ia-details li {
font-size: 0.875rem;
color: var(--nav-text-muted);
padding-left: 1rem;
position: relative;
line-height: 1.5;
}
.ia-details li::before {
content: '—';
position: absolute;
left: 0;
color: var(--nav-text-muted);
opacity: 0.5;
}
/* ── CTAs contribuer ─────────────────────────────────────────────────────────── */
.contribuer-ctas {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
margin-top: 1rem;
}
.btn-primary {
display: inline-flex;
align-items: center;
padding: 0.75rem 1.5rem;
background: var(--nav-accent);
color: var(--nav-text);
border-radius: 8px;
font-size: 0.9rem;
font-weight: 600;
text-decoration: none;
transition: opacity 0.15s;
}
.btn-primary:hover {
opacity: 0.85;
}
.btn-secondary {
display: inline-flex;
align-items: center;
padding: 0.75rem 1.25rem;
background: transparent;
color: var(--nav-text-muted);
border: 1px solid rgba(26, 34, 56, 0.2);
border-radius: 8px;
font-size: 0.9rem;
text-decoration: none;
transition: border-color 0.15s, color 0.15s;
}
.btn-secondary:hover {
border-color: var(--nav-primary-solid);
color: var(--nav-text);
}
/* ── GitHub open source ──────────────────────────────────────────────────────── */
.section-opensource {
border-top: 1px solid var(--nav-bg-alt);
padding-top: 2rem;
}
.btn-github {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.625rem 1.25rem;
background: var(--nav-surface);
border: 1px solid rgba(26, 34, 56, 0.2);
border-radius: 8px;
font-size: 0.875rem;
font-weight: 600;
color: var(--nav-text);
text-decoration: none;
transition: border-color 0.15s, background 0.15s;
margin-top: 0.5rem;
}
.btn-github:hover {
border-color: var(--nav-primary-solid);
background: var(--nav-bg-alt);
}
/* ── Responsive général ──────────────────────────────────────────────────────── */
@media (max-width: 480px) {
.apropos-page {
padding: 1rem 0.75rem 4rem;
}
.section-mission h1 {
font-size: 1.4rem;
}
.contribuer-ctas {
flex-direction: column;
}
.btn-primary,
.btn-secondary {
width: 100%;
justify-content: center;
}
}
</style>

39
pages/agences.vue Normal file
View File

@@ -0,0 +1,39 @@
<template>
<div class="flex flex-col items-center justify-center h-full gap-6" style="background: var(--nav-bg);">
<div class="text-center max-w-md px-6">
<div
class="inline-flex items-center justify-center w-14 h-14 rounded-2xl mb-5"
style="background: var(--nav-bg-alt);"
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="color: var(--nav-text-muted);">
<rect x="3" y="3" width="7" height="7"/>
<rect x="14" y="3" width="7" height="7"/>
<rect x="14" y="14" width="7" height="7"/>
<rect x="3" y="14" width="7" height="7"/>
</svg>
</div>
<h1 class="text-2xl font-bold mb-3" style="color: var(--nav-text);">Agences Inspirantes</h1>
<p class="text-sm leading-relaxed mb-6" style="color: var(--nav-text-muted);">
Cette section répertoriera les agences d'architecture qui incarnent une pratique engagée — écologie politique, auto-construction, architectures vernaculaires, sobriété.
</p>
<p class="text-xs font-semibold uppercase tracking-widest mb-6" style="color: var(--nav-text-muted); opacity: 0.6;">
Bientôt disponible
</p>
<NuxtLink
to="/"
class="inline-flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-semibold transition-all hover:opacity-80"
style="background: var(--nav-primary); color: var(--nav-text-on-primary);"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" aria-hidden="true">
<line x1="19" y1="12" x2="5" y2="12"/>
<polyline points="12 19 5 12 12 5"/>
</svg>
Retour à l'écosystème
</NuxtLink>
</div>
</div>
</template>
<script setup lang="ts">
useHead({ title: 'Agences Inspirantes — AEP (bientôt disponible)' })
</script>

215
pages/ajouter.vue Normal file
View File

@@ -0,0 +1,215 @@
<template>
<div class="max-w-2xl mx-auto px-4 py-8">
<NuxtLink to="/" class="text-sm text-sage-600 hover:text-sage-800 inline-flex items-center gap-1 mb-6">
Retour à la liste
</NuxtLink>
<h1 class="text-2xl font-bold text-gray-900 mb-2">Proposer une fiche</h1>
<p class="text-gray-500 text-sm mb-8">
Tu connais une institution, association ou collectif qui mérite d'être référencé ? Propose-le ici. La fiche sera visible après validation par l'équipe.
</p>
<div v-if="success" class="bg-sage-100 border border-sage-300 text-sage-800 rounded-xl px-6 py-5 text-center">
<p class="font-semibold text-lg mb-1">Merci !</p>
<p class="text-sm">Ta fiche sera visible après modération. On l'examine généralement sous 48h.</p>
<button @click="reset" class="mt-4 text-sage-600 underline text-sm hover:text-sage-800">
Proposer une autre fiche
</button>
</div>
<form v-else @submit.prevent="submit" class="space-y-5">
<!-- Nom -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Nom de l'organisation *</label>
<input
v-model="form.nom"
type="text"
required
placeholder="Ex : UNSFA, Maison de l'Architecture..."
class="w-full px-3 py-2.5 rounded-lg border border-warm-300 focus:outline-none focus:ring-2 focus:ring-sage-400 bg-white text-sm"
/>
</div>
<!-- Type -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Type d'organisation *</label>
<select
v-model="form.type_org"
required
class="w-full px-3 py-2.5 rounded-lg border border-warm-300 focus:outline-none focus:ring-2 focus:ring-sage-400 bg-white text-sm text-gray-700"
>
<option value="" disabled>Choisir un type...</option>
<option value="association">Association</option>
<option value="syndicat">Syndicat</option>
<option value="institution">Institution</option>
<option value="reseau">Réseau</option>
<option value="collectif">Collectif</option>
<option value="ecole">École</option>
<option value="media">Média</option>
</select>
</div>
<!-- Description -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">
Description *
<span class="font-normal text-gray-400">(environ 200 caractères)</span>
</label>
<textarea
v-model="form.description"
required
rows="4"
placeholder="Présente l'organisation en quelques mots : ses missions, son public cible, ce qu'elle apporte à la communauté..."
class="w-full px-3 py-2.5 rounded-lg border border-warm-300 focus:outline-none focus:ring-2 focus:ring-sage-400 bg-white text-sm resize-none"
/>
<p class="text-xs text-gray-400 mt-1">{{ form.description.length }} caractères</p>
</div>
<!-- Lien -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Site web <span class="font-normal text-gray-400">(facultatif)</span></label>
<input
v-model="form.lien"
type="url"
placeholder="https://..."
class="w-full px-3 py-2.5 rounded-lg border border-warm-300 focus:outline-none focus:ring-2 focus:ring-sage-400 bg-white text-sm"
/>
</div>
<!-- Tags -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">
Tags <span class="font-normal text-gray-400">(séparés par des virgules)</span>
</label>
<input
v-model="form.tags"
type="text"
placeholder="Ex : formation, concours, droit, Paris..."
class="w-full px-3 py-2.5 rounded-lg border border-warm-300 focus:outline-none focus:ring-2 focus:ring-sage-400 bg-white text-sm"
/>
</div>
<!-- Séparateur -->
<div class="border-t border-warm-200 pt-5">
<p class="text-sm font-medium text-gray-700 mb-4">Ton expérience avec cette organisation</p>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Ton prénom *</label>
<input
v-model="form.auteur"
type="text"
required
placeholder="Marie"
class="w-full px-3 py-2.5 rounded-lg border border-warm-300 focus:outline-none focus:ring-2 focus:ring-sage-400 bg-white text-sm"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">
Ton avis / expérience *
<span class="font-normal text-gray-400">(environ 500 caractères)</span>
</label>
<textarea
v-model="form.avis"
required
rows="5"
placeholder="Pourquoi tu recommandes cet endroit ? Qu'est-ce que ça t'a apporté ?"
class="w-full px-3 py-2.5 rounded-lg border border-warm-300 focus:outline-none focus:ring-2 focus:ring-sage-400 bg-white text-sm resize-none"
/>
<p class="text-xs text-gray-400 mt-1">{{ form.avis.length }} caractères</p>
</div>
</div>
</div>
<!-- Erreur -->
<div v-if="error" class="text-red-600 text-sm bg-red-50 border border-red-200 rounded-lg px-4 py-3">
Une erreur s'est produite. Réessaie dans quelques instants.
</div>
<button
type="submit"
:disabled="submitting"
class="w-full bg-sage-600 hover:bg-sage-700 disabled:opacity-50 text-white font-semibold py-3 rounded-xl transition-colors text-sm"
>
{{ submitting ? 'Envoi en cours...' : 'Proposer cette fiche' }}
</button>
<p class="text-xs text-gray-400 text-center">
Ta fiche sera examinée par l'équipe avant publication.
</p>
</form>
</div>
</template>
<script setup lang="ts">
const form = reactive({
nom: '',
type_org: '',
description: '',
lien: '',
tags: '',
auteur: '',
avis: '',
})
const submitting = ref(false)
const success = ref(false)
const error = ref(false)
async function submit() {
submitting.value = true
error.value = false
try {
// 1. POST organisation avec status=pending
const orgResult: any = await $fetch('/api/organisations', {
method: 'POST',
body: {
nom: form.nom,
type_org: form.type_org,
description: form.description,
lien: form.lien || null,
tags: form.tags || null,
status: 'pending',
},
})
// 2. POST avis lié à l'organisation
const newOrgId = orgResult?.Id ?? orgResult?.id
if (newOrgId && form.avis.trim()) {
await $fetch('/api/avis', {
method: 'POST',
body: {
organisation_id: newOrgId,
auteur: form.auteur,
texte: form.avis,
status: 'pending',
},
})
}
success.value = true
} catch (e) {
error.value = true
} finally {
submitting.value = false
}
}
function reset() {
Object.assign(form, {
nom: '',
type_org: '',
description: '',
lien: '',
tags: '',
auteur: '',
avis: '',
})
success.value = false
error.value = false
}
useHead({ title: 'Proposer une fiche — AEP' })
</script>

795
pages/contribuer.vue Normal file
View File

@@ -0,0 +1,795 @@
<template>
<div class="contribuer-page">
<div class="contribuer-inner">
<!-- Retour -->
<NuxtLink to="/" class="back-link">
Retour à la carte
</NuxtLink>
<!-- En-tête -->
<div class="contribuer-header">
<h1>Proposer une ressource</h1>
<p class="contribuer-subtitle">
Tu connais une organisation utile aux architectes qui n'est pas encore référencée ?
Soumets-la ici — une IA enrichira la fiche et on validera sous 7 jours.
</p>
<p class="contribuer-hint">
Si tu n'as pas le temps de tout remplir, laisse-nous juste le lien on extraira les infos du site.
Mais une description de toi, c'est toujours plus vivant et plus précis.
</p>
</div>
<!-- Message succès -->
<div v-if="success" class="success-block" role="status" aria-live="polite">
<div class="success-icon">✓</div>
<h2>Merci !</h2>
<p>Ta fiche est en cours de traitement.</p>
<p class="success-detail">
Une IA va scraper le site et enrichir la description.
Jules (et bientôt une équipe de modération) valide sous 7 jours.
</p>
<button type="button" class="btn-secondary" @click="reset">
Proposer une autre fiche
</button>
</div>
<!-- Formulaire -->
<form v-else @submit.prevent="submit" class="contribuer-form" novalidate>
<!-- Nom -->
<div class="field-group" :class="{ 'field-error': errors.nom }">
<label for="nom">Nom de l'organisation <span class="required">*</span></label>
<input
id="nom"
v-model="form.nom"
type="text"
placeholder="Ex : UNSFA, Maison de l'Architecture..."
autocomplete="organization"
@blur="validateField('nom')"
/>
<span v-if="errors.nom" class="error-msg" role="alert">{{ errors.nom }}</span>
</div>
<!-- URL -->
<div class="field-group" :class="{ 'field-error': errors.url }">
<label for="url">
Site web
<span class="label-hint">(optionnel recommandé pour l'enrichissement IA)</span>
</label>
<input
id="url"
v-model="form.url"
type="url"
placeholder="https://..."
@blur="validateField('url')"
/>
<span v-if="errors.url" class="error-msg" role="alert">{{ errors.url }}</span>
</div>
<!-- Description -->
<div class="field-group" :class="{ 'field-error': errors.description_user }">
<label for="description_user">
Description courte <span class="required">*</span>
<span class="label-hint">(50 à 500 caractères)</span>
</label>
<textarea
id="description_user"
v-model="form.description_user"
rows="4"
placeholder="Présente l'organisation en quelques mots : ses missions, son public, ce qu'elle apporte..."
@blur="validateField('description_user')"
/>
<div class="field-meta">
<span v-if="errors.description_user" class="error-msg" role="alert">
{{ errors.description_user }}
</span>
<span v-else class="char-count" :class="{ 'char-warn': form.description_user.length > 450 }">
{{ form.description_user.length }}/500
</span>
</div>
</div>
<!-- Échelle -->
<div class="field-group" :class="{ 'field-error': errors.echelle }">
<fieldset>
<legend>
Échelle <span class="required">*</span>
<span class="label-hint">(une seule)</span>
</legend>
<div class="radio-group">
<label
v-for="opt in ECHELLES"
:key="opt"
class="radio-label"
:class="{ active: form.echelle === opt }"
>
<input
type="radio"
:value="opt"
v-model="form.echelle"
name="echelle"
@change="validateField('echelle')"
/>
{{ opt }}
</label>
</div>
</fieldset>
<span v-if="errors.echelle" class="error-msg" role="alert">{{ errors.echelle }}</span>
</div>
<!-- Fonctions -->
<div class="field-group" :class="{ 'field-error': errors.fonctions }">
<fieldset>
<legend>
Fonctions <span class="required">*</span>
<span class="label-hint">(1 à 5 — l'ordre de clic = priorité)</span>
</legend>
<div class="checkbox-grid">
<label
v-for="fn in FONCTIONS"
:key="fn"
class="checkbox-label"
:class="{
active: form.fonctions.includes(fn),
disabled: !form.fonctions.includes(fn) && form.fonctions.length >= 5,
}"
>
<input
type="checkbox"
:value="fn"
:checked="form.fonctions.includes(fn)"
:disabled="!form.fonctions.includes(fn) && form.fonctions.length >= 5"
@change="toggleFonction(fn)"
/>
<span class="fn-order" v-if="form.fonctions.includes(fn)">
{{ form.fonctions.indexOf(fn) + 1 }}
</span>
{{ fn }}
</label>
</div>
</fieldset>
<span v-if="errors.fonctions" class="error-msg" role="alert">{{ errors.fonctions }}</span>
</div>
<!-- Territoire -->
<div class="field-group" :class="{ 'field-error': errors.territoire }">
<fieldset>
<legend>
Territoire <span class="required">*</span>
</legend>
<div class="radio-group">
<label
v-for="t in TERRITOIRES"
:key="t"
class="radio-label"
:class="{ active: form.territoire === t }"
>
<input
type="radio"
:value="t"
v-model="form.territoire"
name="territoire"
@change="validateField('territoire')"
/>
{{ t }}
</label>
</div>
</fieldset>
<span v-if="errors.territoire" class="error-msg" role="alert">{{ errors.territoire }}</span>
</div>
<!-- Ville -->
<div class="field-group" :class="{ 'field-error': errors.localisation_ville }">
<label for="localisation_ville">
Ville principale
<span class="label-hint">(optionnel pour la géolocalisation sur la carte)</span>
</label>
<input
id="localisation_ville"
v-model="form.localisation_ville"
type="text"
placeholder="Ex : Paris, Lyon, Bordeaux..."
/>
<span v-if="errors.localisation_ville" class="error-msg" role="alert">
{{ errors.localisation_ville }}
</span>
</div>
<!-- Email -->
<div class="field-group" :class="{ 'field-error': errors.submitted_by_email }">
<label for="submitted_by_email">
Ton email
<span class="label-hint">(optionnel pour le suivi de modération)</span>
</label>
<input
id="submitted_by_email"
v-model="form.submitted_by_email"
type="email"
placeholder="ton@email.fr"
autocomplete="email"
@blur="validateField('submitted_by_email')"
/>
<span v-if="errors.submitted_by_email" class="error-msg" role="alert">
{{ errors.submitted_by_email }}
</span>
</div>
<!-- Erreur globale -->
<div v-if="serverError" class="server-error" role="alert">
<strong>Erreur :</strong> {{ serverError }}
</div>
<!-- Actions -->
<div class="form-actions">
<NuxtLink to="/" class="btn-secondary">Annuler</NuxtLink>
<button
type="submit"
class="btn-primary"
:disabled="submitting"
>
{{ submitting ? 'Envoi en cours...' : 'Proposer la fiche →' }}
</button>
</div>
<p class="form-note">
Ta fiche sera examinée par l'équipe avant publication.
</p>
</form>
</div>
</div>
</template>
<script setup lang="ts">
import { z } from 'zod'
// ── Constantes ────────────────────────────────────────────────────────────────
const ECHELLES = ['National', 'Régional', 'Local'] as const
const TERRITOIRES = ['Métropole', 'Guadeloupe', 'Martinique', 'Guyane', 'La Réunion', 'Mayotte'] as const
const FONCTIONS = [
'Juridique', 'Technique', 'Économique', 'Administratif', 'Chantier',
'Comptabilité', 'Développement', 'Formation', 'Gestion d\'agence', 'Santé mentale',
] as const
// ── Schéma Zod (côté client — miroir du serveur) ──────────────────────────────
const SubmitSchema = z.object({
nom: z.string().min(3, 'Minimum 3 caractères').max(150, 'Maximum 150 caractères').trim(),
url: z.string().url('URL invalide (commencer par https://)').optional().or(z.literal('')),
description_user: z.string().min(50, 'Minimum 50 caractères').max(500, 'Maximum 500 caractères').trim(),
echelle: z.enum(ECHELLES, { errorMap: () => ({ message: 'Sélectionne une échelle' }) }),
fonctions: z.array(z.string()).min(1, 'Sélectionne au moins une fonction').max(5, 'Maximum 5 fonctions'),
territoire: z.enum(TERRITOIRES, { errorMap: () => ({ message: 'Sélectionne un territoire' }) }),
localisation_ville: z.string().max(100).optional(),
submitted_by_email: z.string().email('Email invalide').optional().or(z.literal('')),
})
// ── État du formulaire ────────────────────────────────────────────────────────
const form = reactive({
nom: '',
url: '',
description_user: '',
echelle: '' as typeof ECHELLES[number] | '',
fonctions: [] as string[],
territoire: '' as typeof TERRITOIRES[number] | '',
localisation_ville: '',
submitted_by_email: '',
})
const errors = reactive<Record<string, string>>({})
const submitting = ref(false)
const success = ref(false)
const serverError = ref('')
const trackingUrl = ref<string | null>(null)
// ── Validation champ par champ ────────────────────────────────────────────────
function validateField(field: string) {
const partial = SubmitSchema.partial()
const result = partial.safeParse({ [field]: (form as any)[field] })
if (!result.success) {
const fieldErrors = result.error.flatten().fieldErrors
errors[field] = fieldErrors[field]?.[0] ?? ''
} else {
delete errors[field]
}
}
function validateAll(): boolean {
const result = SubmitSchema.safeParse(form)
if (!result.success) {
const flat = result.error.flatten().fieldErrors
Object.assign(errors, Object.fromEntries(
Object.entries(flat).map(([k, v]) => [k, v?.[0] ?? ''])
))
return false
}
Object.keys(errors).forEach(k => delete errors[k])
return true
}
// ── Gestion fonctions (ordre de clic = priorité) ──────────────────────────────
function toggleFonction(fn: string) {
const idx = form.fonctions.indexOf(fn)
if (idx >= 0) {
form.fonctions.splice(idx, 1)
} else if (form.fonctions.length < 5) {
form.fonctions.push(fn)
}
validateField('fonctions')
}
// ── Soumission ────────────────────────────────────────────────────────────────
async function submit() {
serverError.value = ''
if (!validateAll()) {
// Scroll vers la première erreur
await nextTick()
const firstError = document.querySelector('.field-error')
firstError?.scrollIntoView({ behavior: 'smooth', block: 'center' })
return
}
submitting.value = true
try {
const result: any = await $fetch('/api/submit', {
method: 'POST',
body: {
nom: form.nom,
url: form.url || undefined,
description_user: form.description_user,
echelle: form.echelle,
fonctions: form.fonctions,
territoire: form.territoire,
localisation_ville: form.localisation_ville || undefined,
submitted_by_email: form.submitted_by_email || undefined,
},
})
trackingUrl.value = result.trackingUrl ?? null
success.value = true
} catch (e: any) {
const status = e?.status ?? e?.statusCode
if (status === 429) {
serverError.value = 'Tu as déjà soumis 3 fiches aujourd\'hui. Réessaie demain.'
} else if (status === 422 && e?.data) {
// Erreurs Zod serveur → mapper sur le formulaire
const fieldErrors = e.data
Object.entries(fieldErrors).forEach(([k, v]) => {
errors[k] = Array.isArray(v) ? v[0] : String(v)
})
serverError.value = 'Certains champs sont invalides — vérifie les erreurs ci-dessus.'
} else {
serverError.value = 'Une erreur s\'est produite. Réessaie dans quelques instants.'
}
} finally {
submitting.value = false
}
}
function reset() {
Object.assign(form, {
nom: '', url: '', description_user: '', echelle: '',
fonctions: [], territoire: '', localisation_ville: '', submitted_by_email: '',
})
Object.keys(errors).forEach(k => delete errors[k])
success.value = false
serverError.value = ''
trackingUrl.value = null
}
// ── Meta ──────────────────────────────────────────────────────────────────────
useHead({ title: 'Proposer une ressource — AEP' })
</script>
<style scoped>
/* ── Layout ─────────────────────────────────────────────────────────────────── */
.contribuer-page {
min-height: 100vh;
background: var(--nav-bg);
padding: 1.5rem 1rem 4rem;
}
.contribuer-inner {
max-width: 640px;
margin: 0 auto;
}
/* ── Retour ──────────────────────────────────────────────────────────────────── */
.back-link {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-size: 0.875rem;
color: var(--nav-primary-solid);
opacity: 0.7;
text-decoration: none;
margin-bottom: 1.5rem;
transition: opacity 0.15s;
}
.back-link:hover {
opacity: 1;
}
/* ── En-tête ─────────────────────────────────────────────────────────────────── */
.contribuer-header {
margin-bottom: 2rem;
}
.contribuer-header h1 {
font-size: 1.5rem;
font-weight: 700;
color: var(--nav-text);
margin: 0 0 0.5rem;
}
.contribuer-subtitle {
font-size: 0.9rem;
color: var(--nav-text-muted);
line-height: 1.5;
margin: 0 0 0.5rem;
}
.contribuer-hint {
font-size: 0.82rem;
color: var(--nav-text-muted);
opacity: 0.75;
line-height: 1.5;
margin: 0;
}
/* ── Succès ──────────────────────────────────────────────────────────────────── */
.success-block {
background: var(--nav-surface);
border: 1px solid rgba(26, 34, 56, 0.15);
border-radius: 12px;
padding: 2rem 1.5rem;
text-align: center;
}
.success-icon {
width: 48px;
height: 48px;
background: rgba(26, 34, 56, 0.1);
color: var(--nav-text);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.25rem;
font-weight: 700;
margin: 0 auto 1rem;
}
.success-block h2 {
font-size: 1.25rem;
font-weight: 700;
color: var(--nav-text);
margin: 0 0 0.5rem;
}
.success-block p {
font-size: 0.9rem;
color: var(--nav-text-muted);
margin: 0 0 0.5rem;
line-height: 1.5;
}
.success-detail {
font-size: 0.85rem !important;
}
.success-tracking {
font-size: 0.85rem !important;
margin-top: 1rem !important;
}
.tracking-link {
color: var(--nav-primary-solid);
font-size: 0.8rem;
word-break: break-all;
}
/* ── Formulaire ──────────────────────────────────────────────────────────────── */
.contribuer-form {
display: flex;
flex-direction: column;
gap: 1.25rem;
}
/* ── Champ générique ─────────────────────────────────────────────────────────── */
.field-group {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.field-group label,
.field-group legend {
font-size: 0.875rem;
font-weight: 600;
color: var(--nav-text);
display: block;
}
.field-group fieldset {
border: none;
padding: 0;
margin: 0;
}
.required {
color: #c0392b;
}
.label-hint {
font-weight: 400;
color: var(--nav-text-muted);
font-size: 0.8rem;
margin-left: 0.25rem;
}
.field-group input[type="text"],
.field-group input[type="url"],
.field-group input[type="email"],
.field-group textarea {
width: 100%;
padding: 0.625rem 0.875rem;
border: 1px solid rgba(26, 34, 56, 0.2);
border-radius: 8px;
font-size: 0.9rem;
color: var(--nav-text);
background: var(--nav-surface);
font-family: inherit;
transition: border-color 0.15s, box-shadow 0.15s;
box-sizing: border-box;
}
.field-group input:focus,
.field-group textarea:focus {
outline: none;
border-color: var(--nav-primary-solid);
box-shadow: 0 0 0 2px rgba(245, 179, 66, 0.4);
}
.field-group textarea {
resize: vertical;
min-height: 100px;
}
/* Erreur champ */
.field-error input,
.field-error textarea {
border-color: #c0392b !important;
}
.error-msg {
font-size: 0.8rem;
color: #c0392b;
}
.field-meta {
display: flex;
justify-content: flex-end;
}
.char-count {
font-size: 0.75rem;
color: var(--nav-text-muted);
}
.char-warn {
color: #e67e22;
}
/* ── Radio (Échelle + Territoire) ────────────────────────────────────────────── */
.radio-group {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 0.375rem;
}
.radio-label {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.75rem;
border: 1px solid rgba(26, 34, 56, 0.2);
border-radius: 6px;
font-size: 0.85rem;
color: var(--nav-text);
background: var(--nav-surface);
cursor: pointer;
transition: all 0.15s;
user-select: none;
}
.radio-label input[type="radio"] {
position: absolute;
opacity: 0;
width: 0;
height: 0;
}
.radio-label:hover {
border-color: var(--nav-primary-solid);
background: var(--nav-bg-alt);
}
.radio-label.active {
background: var(--nav-primary);
border-color: transparent;
color: var(--nav-text-on-primary);
}
/* ── Checkboxes (Fonctions) ──────────────────────────────────────────────────── */
.checkbox-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.5rem;
margin-top: 0.375rem;
}
@media (max-width: 400px) {
.checkbox-grid {
grid-template-columns: 1fr;
}
}
.checkbox-label {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.75rem;
border: 1px solid rgba(26, 34, 56, 0.2);
border-radius: 6px;
font-size: 0.85rem;
color: var(--nav-text);
background: var(--nav-surface);
cursor: pointer;
transition: all 0.15s;
user-select: none;
position: relative;
}
.checkbox-label input[type="checkbox"] {
position: absolute;
opacity: 0;
width: 0;
height: 0;
}
.checkbox-label:hover:not(.disabled) {
border-color: var(--nav-primary-solid);
background: var(--nav-bg-alt);
}
.checkbox-label.active {
background: var(--nav-primary);
border-color: transparent;
color: var(--nav-text-on-primary);
}
.checkbox-label.disabled {
opacity: 0.4;
cursor: not-allowed;
}
.fn-order {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
background: var(--nav-accent);
color: var(--nav-text);
border-radius: 50%;
font-size: 0.7rem;
font-weight: 700;
flex-shrink: 0;
}
/* ── Erreur serveur ──────────────────────────────────────────────────────────── */
.server-error {
padding: 0.875rem 1rem;
background: #fdf0ee;
border: 1px solid #e74c3c;
border-radius: 8px;
font-size: 0.875rem;
color: #c0392b;
}
/* ── Actions ──────────────────────────────────────────────────────────────────── */
.form-actions {
display: flex;
gap: 0.75rem;
justify-content: flex-end;
margin-top: 0.5rem;
}
.btn-primary {
padding: 0.75rem 1.5rem;
background: var(--nav-primary);
color: var(--nav-text-on-primary);
border: none;
border-radius: 8px;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
font-family: inherit;
transition: background 0.15s, opacity 0.15s;
}
.btn-primary:hover:not(:disabled) {
background: rgba(26, 34, 56, 0.75);
}
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-secondary {
padding: 0.75rem 1.25rem;
background: transparent;
color: var(--nav-text-muted);
border: 1px solid rgba(26, 34, 56, 0.2);
border-radius: 8px;
font-size: 0.9rem;
cursor: pointer;
font-family: inherit;
text-decoration: none;
display: inline-flex;
align-items: center;
transition: border-color 0.15s, color 0.15s;
}
.btn-secondary:hover {
border-color: var(--nav-primary-solid);
color: var(--nav-text);
}
.form-note {
font-size: 0.75rem;
color: var(--nav-text-muted);
text-align: center;
margin: 0;
}
/* ── Responsive ──────────────────────────────────────────────────────────────── */
@media (max-width: 480px) {
.contribuer-page {
padding: 1rem 0.75rem 3rem;
}
.form-actions {
flex-direction: column-reverse;
}
.btn-primary,
.btn-secondary {
width: 100%;
justify-content: center;
}
}
</style>

123
pages/fiche/[id].vue Normal file
View File

@@ -0,0 +1,123 @@
<template>
<div class="min-h-screen" style="background: var(--nav-bg);">
<div class="max-w-4xl mx-auto px-4 py-6">
<!-- Bouton retour carte (préserve filtres URL) -->
<NuxtLink
:to="retourUrl"
class="inline-flex items-center gap-1.5 text-sm mb-6 rounded-lg px-3 py-1.5 transition-colors"
style="color: var(--nav-text); background: var(--nav-bg-alt);"
aria-label="Retour à la carte"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<line x1="19" y1="12" x2="5" y2="12"/>
<polyline points="12 19 5 12 12 5"/>
</svg>
Retour à la carte
</NuxtLink>
<!-- Chargement -->
<div v-if="pending" class="py-16 text-center text-sm" style="color: var(--nav-text-muted);">
Chargement de la fiche
</div>
<!-- Erreur -->
<div v-else-if="error" class="py-16 text-center">
<p class="text-lg font-semibold mb-2" style="color: var(--nav-text);">Fiche introuvable</p>
<p class="text-sm" style="color: var(--nav-text-muted);">L'organisation demandée n'existe pas ou a été supprimée.</p>
</div>
<!-- Contenu -->
<template v-else-if="org">
<!-- FicheDetail -->
<FicheDetail :org="org" />
<!-- Séparateur -->
<div class="mb-6" style="height: 1px; background: var(--nav-bg-alt);"></div>
<!-- CommentSection -->
<CommentSection :org-id="org.Id" :refresh="commentRefreshTick" />
<!-- CommentForm -->
<CommentForm :org-id="org.Id" @submitted="onCommentSubmitted" />
</template>
</div>
</div>
</template>
<script setup lang="ts">
import type { Org } from '~/types/org'
// ── Params & route ────────────────────────────────────────────────────
const route = useRoute()
const orgId = route.params.id as string
// ── Retour carte — préserve les filtres via sessionStorage ────────────
const retourUrl = ref('/')
onMounted(() => {
if (typeof window !== 'undefined') {
const stored = sessionStorage.getItem('nav_back_filters')
if (stored) {
retourUrl.value = `/?${stored}`
}
}
})
// ── Fetch fiche SSR ───────────────────────────────────────────────────
const { data: org, pending, error } = await useFetch<Org>(`/api/fiche/${orgId}`, {
key: `fiche-${orgId}`,
})
// ── Commentaires — tick de rafraîchissement ───────────────────────────
const commentRefreshTick = ref(0)
function onCommentSubmitted() {
// Incrémenter pour que CommentSection se recharge
commentRefreshTick.value++
}
// ── SEO dynamiques ────────────────────────────────────────────────────
const description = computed(() => {
if (!org.value) return 'Fiche organisation — AEP'
const desc =
org.value.description_enrichie ||
org.value.description_user ||
org.value.description ||
''
return desc.substring(0, 160).trim()
})
useHead({
title: computed(() =>
org.value ? `${org.value.nom} — AEP` : 'Fiche organisation — AEP'
),
meta: [
{
name: 'description',
content: description,
},
{
property: 'og:title',
content: computed(() =>
org.value ? `${org.value.nom} — AEP` : 'AEP'
),
},
{
property: 'og:description',
content: description,
},
{
property: 'og:image',
content: '/og-default.png', // logo par défaut dans public/
},
{
property: 'og:type',
content: 'article',
},
],
})
</script>

568
pages/index.vue Normal file
View File

@@ -0,0 +1,568 @@
<template>
<div class="flex h-full overflow-hidden" style="background: var(--nav-bg);">
<!-- SIDEBAR DESKTOP ( 1024px) -->
<div class="hidden lg:flex w-80 shrink-0 flex-col overflow-hidden">
<NavSidebar
:search="search"
:modeValue="territoireMode"
:echelle="echelle"
:fonctions="fonctions"
:territoire="territoire"
:echelleCount="echelleCount"
:fonctionCount="fonctionCount"
:territoireCount="territoireCount"
:resultCount="filtered.length"
:orgs="filtered"
:selectedId="selectedId"
:hasActiveFilters="hasActiveFilters"
:pending="pending"
@update:search="onSearch"
@update:mode="onMode"
@update:echelle="onEchelle"
@update:fonctions="onFonctions"
@update:territoire="onTerritoire"
@select-org="onSelectOrg"
@hover-org="onHoverOrg"
@reset-filters="resetFilters"
/>
</div>
<!-- ZONE CENTRALE (carte) -->
<main class="flex-1 flex flex-col overflow-hidden relative">
<!-- Indicateur source dev -->
<div
v-if="dataSource === 'seed'"
class="absolute top-2 left-2 z-[500] px-2 py-1 rounded text-xs"
style="background: var(--nav-accent); color: var(--nav-text);"
>
Mode dev données seed
</div>
<!-- VUE DESKTOP : Métropole pleine largeur + DOM-TOM row en bas -->
<div class="hidden lg:flex lg:flex-1 lg:flex-col lg:overflow-hidden">
<!-- Carte Métropole pleine largeur -->
<div class="flex flex-col flex-1 overflow-hidden">
<div class="relative flex-1" style="min-height: 200px;">
<ClientOnly>
<NavMap
ref="navMapRef"
:orgs="metropoleOrgs"
:selectedId="selectedId"
@select-org="onSelectOrg"
/>
<template #fallback>
<div
class="w-full h-full flex items-center justify-center"
style="background: var(--nav-bg-alt); color: var(--nav-text-muted);"
>
Chargement de la carte
</div>
</template>
</ClientOnly>
</div>
<ChatbotPlaceholder @highlightOrgs="onHighlightOrgs" />
</div>
<!-- Bandeau DOM-TOM row horizontale pleine largeur, hauteur fixe -->
<div
class="shrink-0"
style="height: 140px; border-top: 1px solid var(--nav-bg-alt);"
>
<ClientOnly>
<OutremerMap
:orgs="outremerOrgs"
:selectedId="selectedId"
@select-org="onSelectOrg"
/>
<template #fallback>
<div
class="flex items-center justify-center h-full text-sm"
style="color: var(--nav-text-muted);"
>
Chargement
</div>
</template>
</ClientOnly>
</div>
</div>
<!-- VUE MOBILE : Onglets Métro/Outre-mer + carte pleine hauteur + sheet swipable -->
<!-- Onglets Métropolitain / Outre-mer -->
<div class="lg:hidden shrink-0 flex" style="background: var(--nav-surface); border-bottom: 1px solid var(--nav-bg-alt);">
<button
class="flex-1 py-2 text-sm font-medium transition-colors"
:style="mobileMapView === 'metropole'
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
@click="mobileMapView = 'metropole'"
>Métropolitain</button>
<button
class="flex-1 py-2 text-sm font-medium transition-colors"
:style="mobileMapView === 'outremer'
? 'color: var(--nav-text); border-bottom: 2px solid var(--nav-primary-solid);'
: 'color: var(--nav-text-muted); border-bottom: 2px solid transparent;'"
@click="mobileMapView = 'outremer'"
>Outre-mer</button>
</div>
<div class="lg:hidden flex-1 relative overflow-hidden">
<!-- Carte Métropole -->
<div v-show="mobileMapView === 'metropole'" class="absolute inset-0">
<ClientOnly>
<NavMap
ref="navMapMobileRef"
:orgs="metropoleOrgs"
:selectedId="selectedId"
@select-org="onSelectOrgMobile"
/>
<template #fallback>
<div
class="w-full h-full flex items-center justify-center"
style="background: var(--nav-bg-alt); color: var(--nav-text-muted);"
>
Chargement de la carte
</div>
</template>
</ClientOnly>
</div>
<!-- Carte Outre-mer (scroll vertical, pleine largeur) -->
<div v-show="mobileMapView === 'outremer'" class="absolute inset-0 overflow-y-auto" style="background: var(--nav-bg);">
<ClientOnly>
<OutremerMap
:orgs="outremerOrgs"
:selectedId="selectedId"
@select-org="onSelectOrgMobile"
/>
<template #fallback>
<div class="flex items-center justify-center h-48" style="color: var(--nav-text-muted);">
Chargement
</div>
</template>
</ClientOnly>
</div>
<!-- Bottom sheet swipable (Métropole et Outre-mer) -->
<ClientOnly>
<MobileSheet :resultCount="filtered.length" :pending="pending">
<!-- Barre recherche -->
<div class="px-3 pt-2 pb-2" style="border-bottom: 1px solid var(--nav-bg-alt);">
<label class="mobile-search-label" aria-label="Rechercher une organisation">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="color: var(--nav-text-muted); flex-shrink: 0;">
<circle cx="11" cy="11" r="8"/>
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
</svg>
<input
v-model="mobileSearch"
type="search"
placeholder="Rechercher…"
class="mobile-search-input"
autocomplete="off"
@input="onSearch(mobileSearch)"
/>
<button
v-if="mobileSearch"
type="button"
class="mobile-search-clear"
aria-label="Effacer"
@click.stop="mobileSearch = ''; onSearch('')"
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</label>
<!-- Filtres ÉCHELLE chips style FONCTION -->
<div class="mt-2">
<span class="text-xs font-bold uppercase tracking-wide block mb-1" style="color: var(--nav-text-muted);">ÉCHELLE</span>
<div class="flex flex-wrap gap-1">
<span
v-for="opt in ECHELLES"
:key="opt"
class="cursor-pointer px-2 py-0.5 rounded-full text-xs transition-all"
:style="echelle.includes(opt)
? 'background: var(--nav-primary); color: var(--nav-text-on-primary); font-weight: 600;'
: 'background: var(--nav-bg-alt); color: var(--nav-text-muted);'"
@click="toggleEchelle(opt)"
>{{ opt }}</span>
</div>
</div>
<!-- Filtres FONCTION chips flex-wrap -->
<div class="mt-2">
<span class="text-xs font-bold uppercase tracking-wide block mb-1" style="color: var(--nav-text-muted);">FONCTION</span>
<div class="flex flex-wrap gap-1">
<span
v-for="fn in FONCTIONS"
:key="fn"
class="cursor-pointer px-2 py-0.5 rounded-full text-xs transition-all"
:style="fonctions.includes(fn)
? 'background: var(--nav-primary); color: var(--nav-text-on-primary); font-weight: 600;'
: 'background: var(--nav-bg-alt); color: var(--nav-text-muted);'"
@click="toggleFonction(fn)"
>{{ fn }}</span>
</div>
</div>
<button
v-if="hasActiveFilters"
@click="resetFilters"
class="mt-2 text-xs"
style="color: var(--nav-text-muted); text-decoration: underline;"
> Effacer les filtres</button>
</div>
<!-- Compteur + Liste fiches -->
<div class="px-3 py-2">
<div class="text-xs font-bold uppercase tracking-wide mb-2" style="color: var(--nav-text-muted);">
{{ filtered.length }} résultat{{ filtered.length > 1 ? 's' : '' }}
</div>
<div v-if="pending" class="flex items-center justify-center py-8" style="color: var(--nav-text-muted);">
Chargement des fiches
</div>
<div v-else-if="filtered.length === 0" class="text-center py-8">
<p class="text-sm mb-2" style="color: var(--nav-text-muted);">Aucun résultat pour ces filtres.</p>
<button @click="resetFilters" class="text-sm underline" style="color: var(--nav-primary-solid);">
Effacer les filtres
</button>
</div>
<div class="space-y-2">
<div
v-for="org in filtered"
:key="org.Id"
class="block rounded-lg p-3 transition-all cursor-pointer"
:style="selectedId === org.Id
? 'background: var(--nav-bg-alt); border-left: 3px solid var(--nav-accent);'
: 'background: var(--nav-surface); border-left: 3px solid transparent;'"
@click="onSelectOrgMobile(org.Id)"
>
<div class="flex items-start justify-between gap-2">
<span class="font-semibold text-sm leading-snug" style="color: var(--nav-text);">{{ org.nom }}</span>
<span
v-if="org.echelle"
class="shrink-0 px-2 py-0.5 rounded-full text-xs font-medium"
style="background: var(--nav-bg-alt); color: var(--nav-text-muted);"
>{{ org.echelle }}</span>
</div>
<div v-if="fonctionsList(org).length" class="mt-1 flex flex-wrap gap-1">
<span
v-for="fn in fonctionsList(org)"
:key="fn"
class="px-1.5 py-0.5 rounded text-xs"
style="background: var(--nav-bg-alt); color: var(--nav-text-muted);"
>{{ fn }}</span>
</div>
<div v-if="org.localisation_ville" class="mt-1 text-xs" style="color: var(--nav-text-muted);">
{{ org.localisation_ville }}
</div>
</div>
</div>
</div>
</MobileSheet>
</ClientOnly>
</div>
</main>
<!-- MODAL FICHE (desktop) -->
<FicheModal
v-model="ficheModalOpen"
:orgId="ficheModalId"
/>
<!-- BOUTON CHATBOT FLOTTANT (mobile) -->
<button
class="lg:hidden fixed bottom-6 right-4 z-[1000] flex items-center gap-2 px-4 rounded-full shadow-lg"
style="
height: 48px;
background: var(--nav-primary);
opacity: 0.92;
color: var(--nav-text-on-primary);
box-shadow: 0 4px 16px rgba(26,34,56,0.25);
font-family: var(--nav-font);
font-size: 0.875rem;
font-weight: 600;
"
aria-label="Ouvrir l'assistant Chatbot"
@click="chatbotOpen = true"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
</svg>
<span>Chatbot</span>
</button>
<!-- CHATBOT BOTTOM SHEET (mobile) -->
<ChatbotSheet
:modelValue="chatbotOpen"
@update:modelValue="chatbotOpen = $event"
@highlightOrgs="onHighlightOrgs"
/>
</div>
</template>
<script setup lang="ts">
import type { Org } from '~/types/org'
// ── URL query params sync ─────────────────────────────────────────────────
const route = useRoute()
const router = useRouter()
const search = ref<string>((route.query.q as string) ?? '')
const echelle = ref<string[]>(
route.query.echelle
? (route.query.echelle as string).split(',').filter(Boolean)
: []
)
const fonctions = ref<string[]>(
route.query.fonctions
? (route.query.fonctions as string).split(',').filter(Boolean)
: []
)
const territoire = ref<string | null>((route.query.territoire as string) ?? null)
const territoireMode = ref<string>(
(route.query.mode as string) === 'outremer' ? 'outremer' : 'metropole'
)
const selectedId = ref<number | null>(null)
const chatbotOpen = ref(false)
const ficheModalOpen = ref(false)
const ficheModalId = ref<number | null>(null)
const mobileMapView = ref<'metropole' | 'outremer'>('metropole')
// Surlignage temporaire (5 sec) suite à une réponse chatbot
// → sélectionne le premier ID recommandé sur la carte, puis remet à null
let highlightTimer: ReturnType<typeof setTimeout> | null = null
const prevSelectedId = ref<number | null>(null)
function onHighlightOrgs(ids: (number | string)[]) {
if (!ids.length) return
const firstId = typeof ids[0] === 'string' ? parseInt(ids[0], 10) : ids[0]
if (isNaN(firstId)) return
// Sauvegarde la sélection courante
prevSelectedId.value = selectedId.value
selectedId.value = firstId
if (highlightTimer) clearTimeout(highlightTimer)
highlightTimer = setTimeout(() => {
// Restaure la sélection précédente (ou null)
selectedId.value = prevSelectedId.value
prevSelectedId.value = null
highlightTimer = null
}, 5000)
}
// Ref locale barre de recherche mobile (synchronisée avec search via onSearch)
const mobileSearch = ref<string>((route.query.q as string) ?? '')
// Refs vers les instances NavMap (desktop + mobile séparées via deux <ClientOnly>)
const navMapRef = ref<any>(null)
const navMapMobileRef = ref<any>(null)
// Sync URL <-> état filtres
function syncUrl() {
const q: Record<string, string> = {}
if (search.value) q.q = search.value
if (echelle.value.length) q.echelle = echelle.value.join(',')
if (fonctions.value.length) q.fonctions = fonctions.value.join(',')
if (territoire.value) q.territoire = territoire.value
if (territoireMode.value === 'outremer') q.mode = 'outremer'
router.replace({ query: Object.keys(q).length ? q : undefined })
}
// Sauvegarde les filtres courants dans sessionStorage pour le bouton retour des fiches
function storeFiltersForBack() {
if (typeof window === 'undefined') return
const q: Record<string, string> = {}
if (search.value) q.q = search.value
if (echelle.value.length) q.echelle = echelle.value.join(',')
if (fonctions.value.length) q.fonctions = fonctions.value.join(',')
if (territoire.value) q.territoire = territoire.value
if (territoireMode.value === 'outremer') q.mode = 'outremer'
const qs = new URLSearchParams(q).toString()
sessionStorage.setItem('nav_back_filters', qs)
}
function onSearch(v: string) { search.value = v; syncUrl(); storeFiltersForBack() }
function onMode(v: string) { territoireMode.value = v; syncUrl(); storeFiltersForBack() }
function onEchelle(v: string[]) { echelle.value = v; syncUrl(); storeFiltersForBack() }
function onFonctions(v: string[]) { fonctions.value = v; syncUrl(); storeFiltersForBack() }
function onTerritoire(v: string | null) { territoire.value = v; syncUrl(); storeFiltersForBack() }
function onSelectOrg(id: number) {
selectedId.value = selectedId.value === id ? null : id
// Desktop : ouvrir le modal fiche
if (typeof window !== 'undefined' && window.innerWidth >= 1024) {
ficheModalId.value = id
ficheModalOpen.value = true
}
}
// Tap card mobile → ouvre la fiche détaillée
function onSelectOrgMobile(id: number) {
selectedId.value = id
storeFiltersForBack()
router.push(`/fiche/${id}`)
}
function onHoverOrg(id: number | null) {
if (id !== null) selectedId.value = id
}
const hasActiveFilters = computed(() =>
!!search.value || echelle.value.length > 0 || fonctions.value.length > 0 || !!territoire.value
)
function resetFilters() {
search.value = ''
echelle.value = []
fonctions.value = []
territoire.value = null
router.replace({ query: undefined })
}
// Tagging compact mobile — toggle direct
function toggleEchelle(opt: string) {
if (echelle.value.includes(opt)) {
onEchelle(echelle.value.filter(v => v !== opt))
} else {
onEchelle([...echelle.value, opt])
}
}
function toggleFonction(fn: string) {
if (fonctions.value.includes(fn)) {
onFonctions(fonctions.value.filter(f => f !== fn))
} else {
onFonctions([...fonctions.value, fn])
}
}
// Sync recherche depuis app.vue top nav (via URL ?q=)
watch(() => route.query.q, (v) => {
search.value = (v as string) ?? ''
})
// ── Données ───────────────────────────────────────────────────────────────
const { data, pending, error } = await useFetch<{ list: Org[]; source: string }>('/api/organisations')
const orgs = computed<Org[]>(() => data.value?.list ?? [])
const dataSource = computed(() => data.value?.source ?? 'nocodb')
// Fiche aléatoire — réagit au ?random=1
watch(() => route.query.random, (v) => {
if (v === '1' && orgs.value.length > 0) {
const randomOrg = orgs.value[Math.floor(Math.random() * orgs.value.length)]
router.replace({ path: `/fiche/${randomOrg.Id}` })
}
})
// ── Filtrage côté client ──────────────────────────────────────────────────
const filtered = computed<Org[]>(() => {
let result = orgs.value
if (search.value.trim()) {
const q = search.value.toLowerCase()
result = result.filter(
(o) =>
o.nom?.toLowerCase().includes(q) ||
o.localisation_ville?.toLowerCase().includes(q)
)
}
if (echelle.value.length) {
result = result.filter((o) => o.echelle && echelle.value.includes(o.echelle))
}
if (fonctions.value.length) {
// Garde les orgs qui matchent au moins 1 fonction sélectionnée
result = result.filter((o) => {
const orgFns = (o.tags_fonction ?? '').split(',').map((f) => f.trim()).filter(Boolean)
return fonctions.value.some((fn) => orgFns.includes(fn))
})
// Tri par score pondéré : priorité 1 (1er cliqué) = poids le plus fort
const n = fonctions.value.length
const score = (o: Org) =>
fonctions.value.reduce((s, fn, i) => {
const fns = (o.tags_fonction ?? '').split(',').map((f) => f.trim()).filter(Boolean)
return s + (fns.includes(fn) ? (n - i) : 0)
}, 0)
result = [...result].sort((a, b) => score(b) - score(a))
}
if (territoire.value) {
result = result.filter((o) => o.territoire === territoire.value)
}
return result
})
const DOM_TOM = ['Guadeloupe', 'Martinique', 'Guyane', 'La Réunion', 'Mayotte']
const DOM_TOM_LIST = DOM_TOM
const metropoleOrgs = computed<Org[]>(() =>
filtered.value.filter(o => !o.territoire || !DOM_TOM.includes(o.territoire))
)
const outremerOrgs = computed<Org[]>(() => {
if (territoire.value && DOM_TOM.includes(territoire.value)) {
return filtered.value.filter(o => o.territoire === territoire.value)
}
return filtered.value.filter(o => o.territoire && DOM_TOM.includes(o.territoire))
})
const outremerCountByDom = computed<Record<string, number>>(() => {
const counts: Record<string, number> = {}
DOM_TOM.forEach(d => { counts[d] = 0 })
filtered.value.forEach(o => {
if (o.territoire && DOM_TOM.includes(o.territoire)) {
counts[o.territoire] = (counts[o.territoire] ?? 0) + 1
}
})
return counts
})
// ── Compteurs ─────────────────────────────────────────────────────────────
const ECHELLES = ['National', 'Régional', 'Local'] as const
const ECHELLE_LABELS: Record<string, string> = { National: 'Nat', Régional: 'Rég', Local: 'Loc' }
const FONCTIONS = ['Juridique', 'Technique', 'Économique', 'Administratif', 'Chantier', 'Comptabilité', 'Développement', 'Formation', "Gestion d'agence", 'Santé mentale'] as const
const TERRITOIRES = ['Métropole', 'Guadeloupe', 'Martinique', 'Guyane', 'La Réunion', 'Mayotte']
const echelleCount = computed<Record<string, number>>(() => {
const counts: Record<string, number> = {}
ECHELLES.forEach((e) => { counts[e] = 0 })
orgs.value.forEach((o) => { if (o.echelle) counts[o.echelle] = (counts[o.echelle] ?? 0) + 1 })
return counts
})
const fonctionCount = computed<Record<string, number>>(() => {
const counts: Record<string, number> = {}
FONCTIONS.forEach((f) => { counts[f] = 0 })
orgs.value.forEach((o) => {
const fns = (o.tags_fonction ?? '').split(',').map((f) => f.trim()).filter(Boolean)
fns.forEach((fn) => { counts[fn] = (counts[fn] ?? 0) + 1 })
})
return counts
})
const territoireCount = computed<Record<string, number>>(() => {
const counts: Record<string, number> = {}
TERRITOIRES.forEach((t) => { counts[t] = 0 })
orgs.value.forEach((o) => { if (o.territoire) counts[o.territoire] = (counts[o.territoire] ?? 0) + 1 })
counts['Métropole'] = orgs.value.filter(o => !o.territoire || !DOM_TOM.includes(o.territoire)).length
return counts
})
// ── Helpers ───────────────────────────────────────────────────────────────
function fonctionsList(org: Org): string[] {
return (org.tags_fonction ?? '').split(',').map((f) => f.trim()).filter(Boolean).slice(0, 3)
}
useHead({ title: 'AEP — Cartographie de l\'écologie politique architecturale' })
</script>

38
pages/rag.vue Normal file
View File

@@ -0,0 +1,38 @@
<template>
<div class="flex flex-col items-center justify-center h-full gap-6" style="background: var(--nav-bg);">
<div class="text-center max-w-md px-6">
<div
class="inline-flex items-center justify-center w-14 h-14 rounded-2xl mb-5"
style="background: var(--nav-bg-alt);"
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="color: var(--nav-text-muted);">
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/>
<polyline points="3.27 6.96 12 12.01 20.73 6.96"/>
<line x1="12" y1="22.08" x2="12" y2="12"/>
</svg>
</div>
<h1 class="text-2xl font-bold mb-3" style="color: var(--nav-text);">RAG Retrieval Augmented Generation</h1>
<p class="text-sm leading-relaxed mb-6" style="color: var(--nav-text-muted);">
Une base de connaissances interrogeable par IA textes, rapports, manifestes et ressources documentaires sur l'architecture d'écologie politique.
</p>
<p class="text-xs font-semibold uppercase tracking-widest mb-6" style="color: var(--nav-text-muted); opacity: 0.6;">
Bientôt disponible
</p>
<NuxtLink
to="/"
class="inline-flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-semibold transition-all hover:opacity-80"
style="background: var(--nav-primary); color: var(--nav-text-on-primary);"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" aria-hidden="true">
<line x1="19" y1="12" x2="5" y2="12"/>
<polyline points="12 19 5 12 12 5"/>
</svg>
Retour à l'écosystème
</NuxtLink>
</div>
</div>
</template>
<script setup lang="ts">
useHead({ title: 'RAG AEP (bientôt disponible)' })
</script>

389
pages/signaler.vue Normal file
View File

@@ -0,0 +1,389 @@
<template>
<div class="signaler-page">
<div class="signaler-inner">
<NuxtLink to="/" class="back-link"> Retour à la carte</NuxtLink>
<h1>Signaler un problème</h1>
<p class="signaler-subtitle">
Un bug, un contenu inapproprié, ou une suggestion ? Décris le problème ci-dessous.
</p>
<!-- Succès -->
<div v-if="success" class="success-block" role="status">
<div class="success-icon"></div>
<h2>Merci pour ton retour !</h2>
<p>Le signalement a été envoyé. On s'en occupe.</p>
<button type="button" class="btn-secondary" @click="reset">Envoyer un autre signalement</button>
</div>
<!-- Formulaire -->
<form v-else @submit.prevent="submit" class="signaler-form" novalidate>
<!-- Catégorie -->
<div class="field-group">
<fieldset>
<legend>Ça concerne</legend>
<div class="radio-group">
<label
v-for="cat in CATEGORIES"
:key="cat"
class="radio-label"
:class="{ active: form.category === cat }"
>
<input type="radio" :value="cat" v-model="form.category" name="category" />
{{ cat }}
</label>
</div>
</fieldset>
</div>
<!-- Description -->
<div class="field-group">
<label for="description">Description</label>
<textarea
id="description"
v-model="form.description"
rows="4"
placeholder="Décris le problème..."
/>
</div>
<!-- Email (optionnel) -->
<div class="field-group">
<label for="email">
Ton email
<span class="label-hint">(optionnel — pour un suivi)</span>
</label>
<input id="email" v-model="form.email" type="email" placeholder="ton@email.fr" />
</div>
<div v-if="serverError" class="server-error" role="alert">{{ serverError }}</div>
<div class="form-actions">
<NuxtLink to="/" class="btn-secondary">Annuler</NuxtLink>
<button
type="submit"
class="btn-primary"
:disabled="submitting || !form.category || !form.description.trim()"
>
{{ submitting ? 'Envoi...' : 'Envoyer le signalement' }}
</button>
</div>
</form>
</div>
</div>
</template>
<script setup lang="ts">
const CATEGORIES = ['Une fiche', 'Le chatbot', 'La carte', 'Autre'] as const
const form = reactive({
category: '' as string,
description: '',
email: '',
})
const submitting = ref(false)
const success = ref(false)
const serverError = ref('')
async function submit() {
serverError.value = ''
submitting.value = true
try {
await $fetch('/api/report-general', {
method: 'POST',
body: {
category: form.category,
description: form.description,
email: form.email || undefined,
},
})
success.value = true
} catch (e: any) {
serverError.value = e?.data?.message || 'Erreur lors de l\'envoi. Réessaie.'
} finally {
submitting.value = false
}
}
function reset() {
Object.assign(form, { category: '', description: '', email: '' })
success.value = false
serverError.value = ''
}
useHead({ title: 'Signaler un problème — AEP' })
</script>
<style scoped>
/* ── Layout ─────────────────────────────────────────────────────────────────── */
.signaler-page {
min-height: 100vh;
background: var(--nav-bg);
padding: 1.5rem 1rem 120px;
}
.signaler-inner {
max-width: 640px;
margin: 0 auto;
}
/* ── Retour ──────────────────────────────────────────────────────────────────── */
.back-link {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-size: 0.875rem;
color: var(--nav-primary-solid);
opacity: 0.7;
text-decoration: none;
margin-bottom: 1.5rem;
transition: opacity 0.15s;
}
.back-link:hover {
opacity: 1;
}
/* ── En-tête ─────────────────────────────────────────────────────────────────── */
h1 {
font-size: 1.5rem;
font-weight: 700;
color: var(--nav-text);
margin: 0 0 0.5rem;
}
.signaler-subtitle {
font-size: 0.9rem;
color: var(--nav-text-muted);
line-height: 1.5;
margin: 0 0 2rem;
}
/* ── Succès ──────────────────────────────────────────────────────────────────── */
.success-block {
background: var(--nav-surface);
border: 1px solid rgba(26, 34, 56, 0.15);
border-radius: 12px;
padding: 2rem 1.5rem;
text-align: center;
}
.success-icon {
width: 48px;
height: 48px;
background: rgba(26, 34, 56, 0.1);
color: var(--nav-text);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.25rem;
font-weight: 700;
margin: 0 auto 1rem;
}
.success-block h2 {
font-size: 1.25rem;
font-weight: 700;
color: var(--nav-text);
margin: 0 0 0.5rem;
}
.success-block p {
font-size: 0.9rem;
color: var(--nav-text-muted);
margin: 0 0 1rem;
line-height: 1.5;
}
/* ── Formulaire ──────────────────────────────────────────────────────────────── */
.signaler-form {
display: flex;
flex-direction: column;
gap: 1.25rem;
}
/* ── Champ générique ─────────────────────────────────────────────────────────── */
.field-group {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.field-group label,
.field-group legend {
font-size: 0.875rem;
font-weight: 600;
color: var(--nav-text);
display: block;
}
.field-group fieldset {
border: none;
padding: 0;
margin: 0;
}
.label-hint {
font-weight: 400;
color: var(--nav-text-muted);
font-size: 0.8rem;
margin-left: 0.25rem;
}
.field-group input[type="email"],
.field-group textarea {
width: 100%;
padding: 0.625rem 0.875rem;
border: 1px solid rgba(26, 34, 56, 0.2);
border-radius: 8px;
font-size: 0.9rem;
color: var(--nav-text);
background: var(--nav-surface);
font-family: inherit;
transition: border-color 0.15s, box-shadow 0.15s;
box-sizing: border-box;
}
.field-group input:focus,
.field-group textarea:focus {
outline: none;
border-color: var(--nav-primary-solid);
box-shadow: 0 0 0 2px rgba(245, 179, 66, 0.4);
}
.field-group textarea {
resize: vertical;
min-height: 100px;
}
/* ── Radio (Catégorie) ────────────────────────────────────────────────────────── */
.radio-group {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 0.375rem;
}
.radio-label {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.75rem;
border: 1px solid rgba(26, 34, 56, 0.2);
border-radius: 6px;
font-size: 0.85rem;
color: var(--nav-text);
background: var(--nav-surface);
cursor: pointer;
transition: all 0.15s;
user-select: none;
}
.radio-label input[type="radio"] {
position: absolute;
opacity: 0;
width: 0;
height: 0;
}
.radio-label:hover {
border-color: var(--nav-primary-solid);
background: var(--nav-bg-alt);
}
.radio-label.active {
background: var(--nav-primary);
border-color: transparent;
color: var(--nav-text-on-primary);
}
/* ── Erreur serveur ──────────────────────────────────────────────────────────── */
.server-error {
padding: 0.875rem 1rem;
background: #fdf0ee;
border: 1px solid #e74c3c;
border-radius: 8px;
font-size: 0.875rem;
color: #c0392b;
}
/* ── Actions ──────────────────────────────────────────────────────────────────── */
.form-actions {
display: flex;
gap: 0.75rem;
justify-content: flex-end;
margin-top: 0.5rem;
}
.btn-primary {
padding: 0.75rem 1.5rem;
background: var(--nav-primary);
color: var(--nav-text-on-primary);
border: none;
border-radius: 8px;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
font-family: inherit;
transition: background 0.15s, opacity 0.15s;
}
.btn-primary:hover:not(:disabled) {
background: rgba(26, 34, 56, 0.75);
}
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-secondary {
padding: 0.75rem 1.25rem;
background: transparent;
color: var(--nav-text-muted);
border: 1px solid rgba(26, 34, 56, 0.2);
border-radius: 8px;
font-size: 0.9rem;
cursor: pointer;
font-family: inherit;
text-decoration: none;
display: inline-flex;
align-items: center;
transition: border-color 0.15s, color 0.15s;
}
.btn-secondary:hover {
border-color: var(--nav-primary-solid);
color: var(--nav-text);
}
/* ── Responsive ──────────────────────────────────────────────────────────────── */
@media (max-width: 480px) {
.signaler-page {
padding: 1rem 0.75rem 3rem;
}
.form-actions {
flex-direction: column-reverse;
}
.btn-primary,
.btn-secondary {
width: 100%;
justify-content: center;
}
}
</style>