feat(aep): M1 — formulaire /proposer assoupli (liens + texte libre + chip)

Le contributeur colle un lien (ou plusieurs) et explique en texte libre
pourquoi c'est pertinent, sans template ni minimum de caractères. Le type
devient une suggestion optionnelle (chips). Les 5 formulaires typés restent
disponibles derrière « Mode détaillé » (replié par défaut).

- utils/submitLibre.ts : schéma Zod partagé client/serveur + mapping vers les
  colonnes NocoDB existantes (aucun changement de schéma) — table orgas par
  défaut, table ressources_references si le chip « Références » est choisi.
- components/FormLibre.vue : liens dynamiques (1-10), texte libre, chips,
  email optionnel, honeypot.
- server/api/submit/index.post.ts : branche mode=libre en amont du schéma
  typé existant ; email Jules remplacé par ntfy (server/utils/notify.ts) —
  jamais l'email ni le texte du contributeur dans la notif.
- scripts/test-submit-libre.mjs : 6 cas contre le vrai schéma Zod (via jiti).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jules
2026-09-27 15:57:11 +02:00
co-authored by Claude Opus 5.5
parent 58937a04a3
commit 09d1dc9d5f
6 changed files with 696 additions and 56 deletions
+70 -19
View File
@@ -6,8 +6,8 @@
<div class="proposer-header">
<h1>Proposer une ressource</h1>
<p class="proposer-subtitle">
Tu connais quelque chose d'utile qui n'est pas encore référencé sur AEP ?
Choisis la catégorie ci-dessous et soumets-le — on valide sous 7 jours.
Colle un lien (ou plusieurs), dis en quelques mots pourquoi c'est utile — on s'occupe du reste.
On valide manuellement avant publication.
</p>
</div>
@@ -20,23 +20,42 @@
</div>
<template v-else>
<div class="tabs-bar">
<button
v-for="tab in TABS"
:key="tab.id"
type="button"
class="tab-btn"
:class="{ 'tab-btn--active': activeTab === tab.id }"
@click="activeTab = tab.id"
>{{ tab.label }}</button>
</div>
<FormLibre :preset-type="presetType" @success="onSuccess" />
<div class="tab-content">
<FormEcosysteme v-if="activeTab === 'ecosysteme'" @success="onSuccess" />
<FormReseau v-else-if="activeTab === 'reseau'" @success="onSuccess" />
<FormJobs v-else-if="activeTab === 'jobs'" @success="onSuccess" />
<FormOutils v-else-if="activeTab === 'outils'" @success="onSuccess" />
<FormReferences v-else-if="activeTab === 'references'" @success="onSuccess" />
<div class="mode-detaille">
<button
type="button"
class="mode-detaille-toggle"
:aria-expanded="detailleOuvert"
aria-controls="mode-detaille-panel"
@click="detailleOuvert = !detailleOuvert"
>
{{ detailleOuvert ? '− Fermer le mode détaillé' : '+ Mode détaillé (formulaires par catégorie)' }}
</button>
<div v-if="detailleOuvert" id="mode-detaille-panel" class="mode-detaille-panel">
<p class="mode-detaille-hint">
Pour aller plus loin qu'un lien + quelques mots : échelle, fonctions, ville... un formulaire par catégorie.
</p>
<div class="tabs-bar">
<button
v-for="tab in TABS"
:key="tab.id"
type="button"
class="tab-btn"
:class="{ 'tab-btn--active': activeTab === tab.id }"
@click="activeTab = tab.id"
>{{ tab.label }}</button>
</div>
<div class="tab-content">
<FormEcosysteme v-if="activeTab === 'ecosysteme'" @success="onSuccess" />
<FormReseau v-else-if="activeTab === 'reseau'" @success="onSuccess" />
<FormJobs v-else-if="activeTab === 'jobs'" @success="onSuccess" />
<FormOutils v-else-if="activeTab === 'outils'" @success="onSuccess" />
<FormReferences v-else-if="activeTab === 'references'" @success="onSuccess" />
</div>
</div>
</div>
</template>
</div>
@@ -44,6 +63,8 @@
</template>
<script setup lang="ts">
import { TYPES_SUGGERES, type TypeSuggere } from '~/utils/submitLibre'
const TABS = [
{ id: 'ecosysteme', label: 'Écosystème / Entraide' },
{ id: 'reseau', label: 'Réseau AEP' },
@@ -52,10 +73,15 @@ const TABS = [
{ id: 'references', label: 'Références (bibliothèque)' },
] as const
// ?categorie=outils (lien « Proposer une ressource » des têtes d'échelle) présélectionne l'onglet
// ?categorie=outils (lien « Proposer une ressource » des têtes d'échelle) présélectionne
// le chip du formulaire assoupli ET l'onglet du mode détaillé si l'un ou l'autre est ouvert.
const route = useRoute()
const categorie = String(route.query.categorie ?? '')
const presetType = computed<TypeSuggere | undefined>(() =>
(TYPES_SUGGERES as readonly string[]).includes(categorie) ? (categorie as TypeSuggere) : undefined,
)
const activeTab = ref<string>(TABS.some(t => t.id === categorie) ? categorie : 'ecosysteme')
const detailleOuvert = ref(false)
const success = ref(false)
function onSuccess() {
@@ -65,6 +91,7 @@ function onSuccess() {
function reset() {
success.value = false
activeTab.value = 'ecosysteme'
detailleOuvert.value = false
}
useHead({ title: 'Proposer une ressource — AEP' })
@@ -106,6 +133,30 @@ useHead({ title: 'Proposer une ressource — AEP' })
margin: 0;
}
.mode-detaille {
margin-top: 2rem;
border-top: 1px solid var(--nav-bg-alt);
padding-top: 1.25rem;
}
.mode-detaille-toggle {
background: transparent;
border: none;
color: var(--nav-text-muted);
font-size: 0.85rem;
cursor: pointer;
font-family: inherit;
padding: 0.25rem 0;
text-decoration: underline;
text-underline-offset: 2px;
}
.mode-detaille-toggle:hover { color: var(--nav-text); }
.mode-detaille-panel { margin-top: 1rem; }
.mode-detaille-hint {
font-size: 0.8rem;
color: var(--nav-text-muted);
margin: 0 0 1rem;
}
.tabs-bar {
display: flex;
flex-wrap: wrap;