Files
nav-carte/pages/proposer.vue
T
JulesandClaude Opus 5.5 09d1dc9d5f 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>
2026-09-27 15:57:11 +02:00

249 lines
6.8 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="proposer-page">
<div class="proposer-inner">
<NuxtLink to="/" class="back-link">← Retour à la carte</NuxtLink>
<div class="proposer-header">
<h1>Proposer une ressource</h1>
<p class="proposer-subtitle">
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>
<div v-if="success" class="success-block" role="status" aria-live="polite">
<div class="success-icon">✓</div>
<h2>Merci !</h2>
<p>Ta proposition est en attente de modération.</p>
<p class="success-detail">Jules valide manuellement chaque entrée avant publication.</p>
<button type="button" class="btn-secondary" @click="reset">Proposer autre chose</button>
</div>
<template v-else>
<FormLibre :preset-type="presetType" @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>
</div>
</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' },
{ id: 'jobs', label: 'Jobs' },
{ id: 'outils', label: 'Outils' },
{ id: 'references', label: 'Références (bibliothèque)' },
] as const
// ?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() {
success.value = true
}
function reset() {
success.value = false
activeTab.value = 'ecosysteme'
detailleOuvert.value = false
}
useHead({ title: 'Proposer une ressource — AEP' })
</script>
<style scoped>
.proposer-page {
min-height: 100vh;
background: var(--nav-bg);
padding: 1.5rem 1rem 4rem;
}
.proposer-inner {
max-width: 680px;
margin: 0 auto;
}
.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; }
.proposer-header { margin-bottom: 1.5rem; }
.proposer-header h1 {
font-size: 1.5rem;
font-weight: 700;
color: var(--nav-text);
margin: 0 0 0.5rem;
}
.proposer-subtitle {
font-size: 0.9rem;
color: var(--nav-text-muted);
line-height: 1.5;
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;
gap: 0.25rem;
margin-bottom: 1.5rem;
border-bottom: 1px solid var(--nav-bg-alt);
padding-bottom: 0;
}
.tab-btn {
padding: 0.5rem 0.9rem;
font-size: 0.8rem;
font-weight: 500;
color: var(--nav-text-muted);
background: transparent;
border: none;
border-bottom: 2px solid transparent;
cursor: pointer;
font-family: inherit;
transition: color 0.15s, border-color 0.15s;
white-space: nowrap;
}
.tab-btn:hover { color: var(--nav-text); }
.tab-btn--active {
color: var(--nav-text);
border-bottom-color: var(--nav-primary-solid);
font-weight: 600;
}
.tab-content {
min-height: 300px;
}
.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; }
.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;
margin-top: 0.5rem;
transition: border-color 0.15s, color 0.15s;
}
.btn-secondary:hover {
border-color: var(--nav-primary-solid);
color: var(--nav-text);
}
@media (max-width: 480px) {
.proposer-page { padding: 1rem 0.75rem 3rem; }
.tabs-bar { overflow-x: auto; flex-wrap: nowrap; }
}
</style>