feat(proposer): interface 5 onglets + soumission Références vers NocoDB
- Page /proposer avec 5 onglets (Écosystème/Réseau/Jobs/Outils/Références RAG) - 5 form components + form-styles.css partagé - Submit multi-type avec submission_type vers NocoDB - Server route references.post.ts réécrite pour NocoDB (table ressources_references) - redirect 301 /contribuer → /proposer - Suppression references-pending.json
This commit is contained in:
134
components/FormOutils.vue
Normal file
134
components/FormOutils.vue
Normal file
@@ -0,0 +1,134 @@
|
||||
<template>
|
||||
<form @submit.prevent="submit" class="proposer-form" novalidate>
|
||||
<div class="field-group" :class="{ 'field-error': errors.nom }">
|
||||
<label for="out-nom">Nom de l'outil <span class="required">*</span></label>
|
||||
<input id="out-nom" v-model="form.nom" type="text" placeholder="Ex : Simulateur Autonomie, FreeCAD..." @blur="validateField('nom')" />
|
||||
<span v-if="errors.nom" class="error-msg" role="alert">{{ errors.nom }}</span>
|
||||
</div>
|
||||
|
||||
<div class="field-group">
|
||||
<label>Catégorie <span class="label-hint">(optionnelle)</span></label>
|
||||
<div class="radio-group">
|
||||
<label v-for="c in CATEGORIES" :key="c" class="radio-label" :class="{ active: form.categorie === c }">
|
||||
<input type="radio" :value="c" v-model="form.categorie" name="out-categorie" />{{ LABELS[c] }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field-group" :class="{ 'field-error': errors.url }">
|
||||
<label for="out-url">Site web / lien <span class="label-hint">(optionnel — si pas de lien direct)</span></label>
|
||||
<input id="out-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>
|
||||
|
||||
<div class="field-group" :class="{ 'field-error': errors.description_user }">
|
||||
<label for="out-desc">Description courte <span class="required">*</span> <span class="label-hint">(50 à 500 caractères)</span></label>
|
||||
<textarea id="out-desc" v-model="form.description_user" rows="4" placeholder="Décris l'outil : à quoi il sert, pour quel usage, ce qui le rend utile..." @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>
|
||||
|
||||
<div class="field-group" :class="{ 'field-error': errors.submitted_by_email }">
|
||||
<label for="out-email">Ton email <span class="label-hint">(optionnel — pour le suivi)</span></label>
|
||||
<input id="out-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>
|
||||
|
||||
<div v-if="serverError" class="server-error" role="alert"><strong>Erreur :</strong> {{ serverError }}</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn-primary" :disabled="submitting">{{ submitting ? 'Envoi en cours...' : 'Proposer →' }}</button>
|
||||
</div>
|
||||
<p class="form-note">Ta proposition sera examinée par l'équipe avant publication.</p>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { z } from 'zod'
|
||||
|
||||
const CATEGORIES = ['simulateur', 'logiciel', 'guide', 'autre'] as const
|
||||
const LABELS: Record<string, string> = { simulateur: 'Simulateur', logiciel: 'Logiciel / app', guide: 'Guide / ressource', autre: 'Autre' }
|
||||
|
||||
const Schema = z.object({
|
||||
nom: z.string().min(3, 'Minimum 3 caractères').max(150, 'Maximum 150 caractères').trim(),
|
||||
url: z.string().url('URL invalide').optional().or(z.literal('')),
|
||||
description_user: z.string().min(50, 'Minimum 50 caractères').max(500, 'Maximum 500 caractères').trim(),
|
||||
categorie: z.string().optional(),
|
||||
submitted_by_email: z.string().email('Email invalide').optional().or(z.literal('')),
|
||||
})
|
||||
|
||||
const emit = defineEmits<{ success: [] }>()
|
||||
|
||||
const form = reactive({
|
||||
nom: '', url: '', description_user: '', categorie: '', submitted_by_email: '',
|
||||
})
|
||||
const errors = reactive<Record<string, string>>({})
|
||||
const submitting = ref(false)
|
||||
const serverError = ref('')
|
||||
|
||||
function validateField(field: string) {
|
||||
const partial = Schema.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 = Schema.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
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
serverError.value = ''
|
||||
if (!validateAll()) {
|
||||
await nextTick()
|
||||
const firstError = document.querySelector('.field-error')
|
||||
firstError?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
await $fetch('/api/submit', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
submission_type: 'outil',
|
||||
nom: form.nom,
|
||||
url: form.url || undefined,
|
||||
description_user: form.description_user,
|
||||
categorie: form.categorie || undefined,
|
||||
submitted_by_email: form.submitted_by_email || undefined,
|
||||
},
|
||||
})
|
||||
emit('success')
|
||||
} 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) {
|
||||
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
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@import './form-styles.css';
|
||||
</style>
|
||||
Reference in New Issue
Block a user