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:
184
components/FormEcosysteme.vue
Normal file
184
components/FormEcosysteme.vue
Normal file
@@ -0,0 +1,184 @@
|
||||
<template>
|
||||
<form @submit.prevent="submit" class="proposer-form" novalidate>
|
||||
<div class="field-group" :class="{ 'field-error': errors.nom }">
|
||||
<label for="eco-nom">Nom de l'organisation <span class="required">*</span></label>
|
||||
<input id="eco-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>
|
||||
|
||||
<div class="field-group" :class="{ 'field-error': errors.url }">
|
||||
<label for="eco-url">Site web <span class="label-hint">(optionnel — recommandé pour l'enrichissement IA)</span></label>
|
||||
<input id="eco-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="eco-desc">Description courte <span class="required">*</span> <span class="label-hint">(50 à 500 caractères)</span></label>
|
||||
<textarea id="eco-desc" 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>
|
||||
|
||||
<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="eco-echelle" @change="validateField('echelle')" />{{ opt }}
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
<span v-if="errors.echelle" class="error-msg" role="alert">{{ errors.echelle }}</span>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
<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="eco-territoire" @change="validateField('territoire')" />{{ t }}
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
<span v-if="errors.territoire" class="error-msg" role="alert">{{ errors.territoire }}</span>
|
||||
</div>
|
||||
|
||||
<div class="field-group" :class="{ 'field-error': errors.localisation_ville }">
|
||||
<label for="eco-ville">Ville principale <span class="label-hint">(optionnel — pour la géolocalisation sur la carte)</span></label>
|
||||
<input id="eco-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>
|
||||
|
||||
<div class="field-group" :class="{ 'field-error': errors.submitted_by_email }">
|
||||
<label for="eco-email">Ton email <span class="label-hint">(optionnel — pour le suivi de modération)</span></label>
|
||||
<input id="eco-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 la fiche →' }}</button>
|
||||
</div>
|
||||
<p class="form-note">Ta fiche sera examinée par l'équipe avant publication.</p>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { z } from 'zod'
|
||||
|
||||
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
|
||||
|
||||
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 (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('')),
|
||||
})
|
||||
|
||||
const emit = defineEmits<{ success: [] }>()
|
||||
|
||||
const form = reactive({
|
||||
nom: '', url: '', description_user: '', echelle: '' as string,
|
||||
fonctions: [] as string[], territoire: '' as string,
|
||||
localisation_ville: '', 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
|
||||
}
|
||||
|
||||
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')
|
||||
}
|
||||
|
||||
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: 'ecosysteme',
|
||||
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,
|
||||
},
|
||||
})
|
||||
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>
|
||||
134
components/FormJobs.vue
Normal file
134
components/FormJobs.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="job-nom">Nom de la plateforme / job board <span class="required">*</span></label>
|
||||
<input id="job-nom" v-model="form.nom" type="text" placeholder="Ex : Houzz, Architoo, BOAMP..." @blur="validateField('nom')" />
|
||||
<span v-if="errors.nom" class="error-msg" role="alert">{{ errors.nom }}</span>
|
||||
</div>
|
||||
|
||||
<div class="field-group">
|
||||
<label for="job-type">Type de plateforme <span class="label-hint">(optionnel)</span></label>
|
||||
<div class="radio-group">
|
||||
<label v-for="t in TYPES" :key="t" class="radio-label" :class="{ active: form.type_plateforme === t }">
|
||||
<input type="radio" :value="t" v-model="form.type_plateforme" name="job-type" />{{ LABELS[t] }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field-group" :class="{ 'field-error': errors.url }">
|
||||
<label for="job-url">Site web <span class="required">*</span></label>
|
||||
<input id="job-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="job-desc">Description courte <span class="required">*</span> <span class="label-hint">(50 à 500 caractères)</span></label>
|
||||
<textarea id="job-desc" v-model="form.description_user" rows="4" placeholder="Décris la plateforme : qui la gère, quel type de missions, quel modèle économique..." @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="job-email">Ton email <span class="label-hint">(optionnel — pour le suivi)</span></label>
|
||||
<input id="job-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 TYPES = ['mise-en-relation', 'appel-offre-public', 'communaute-pro'] as const
|
||||
const LABELS: Record<string, string> = { 'mise-en-relation': 'Mise en relation (B2C)', 'appel-offre-public': 'Appels d\'offres publics', 'communaute-pro': 'Communauté pro' }
|
||||
|
||||
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 (commencer par https://)'),
|
||||
description_user: z.string().min(50, 'Minimum 50 caractères').max(500, 'Maximum 500 caractères').trim(),
|
||||
type_plateforme: 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: '', type_plateforme: '', 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: 'job',
|
||||
nom: form.nom,
|
||||
url: form.url,
|
||||
description_user: form.description_user,
|
||||
type_plateforme: form.type_plateforme || 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>
|
||||
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>
|
||||
142
components/FormReferences.vue
Normal file
142
components/FormReferences.vue
Normal file
@@ -0,0 +1,142 @@
|
||||
<template>
|
||||
<form @submit.prevent="submit" class="proposer-form" novalidate>
|
||||
<div class="field-group" :class="{ 'field-error': errors.titre }">
|
||||
<label for="ref-titre">Titre du livre / document <span class="required">*</span></label>
|
||||
<input id="ref-titre" v-model="form.titre" type="text" placeholder="Ex : Repenser l'architecture..." @blur="validateField('titre')" />
|
||||
<span v-if="errors.titre" class="error-msg" role="alert">{{ errors.titre }}</span>
|
||||
</div>
|
||||
|
||||
<div class="field-group" :class="{ 'field-error': errors.auteur }">
|
||||
<label for="ref-auteur">Auteur·ice(s) <span class="required">*</span></label>
|
||||
<input id="ref-auteur" v-model="form.auteur" type="text" placeholder="Ex : Philippe Rahm, Doina Petrescu..." @blur="validateField('auteur')" />
|
||||
<span v-if="errors.auteur" class="error-msg" role="alert">{{ errors.auteur }}</span>
|
||||
</div>
|
||||
|
||||
<div class="field-group" :class="{ 'field-error': errors.url }">
|
||||
<label for="ref-url">Lien <span class="label-hint">(optionnel — éditeur, статье, PDF…)</span></label>
|
||||
<input id="ref-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 }">
|
||||
<label for="ref-desc">Description de l'ouvrage <span class="required">*</span> <span class="label-hint">(50 à 1000 caractères)</span></label>
|
||||
<textarea id="ref-desc" v-model="form.description" rows="4" placeholder="Présente l'ouvrage : thèse principale, approche, pourquoi c'est important..." @blur="validateField('description')" />
|
||||
<div class="field-meta">
|
||||
<span v-if="errors.description" class="error-msg" role="alert">{{ errors.description }}</span>
|
||||
<span v-else class="char-count" :class="{ 'char-warn': form.description.length > 900 }">{{ form.description.length }}/1000</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field-group">
|
||||
<label for="ref-pertinence">Pertinence pour le RAG <span class="label-hint">(optionnel — en quoi cette référence est utile ?)</span></label>
|
||||
<textarea id="ref-pertinence" v-model="form.pertinence_rag" rows="2" placeholder="Ex : Excellente source sur l'architecture participative..." maxlength="500" />
|
||||
</div>
|
||||
|
||||
<div class="field-group">
|
||||
<label for="ref-hashtags">Hashtags <span class="label-hint">(optionnel — séparés par des virgules)</span></label>
|
||||
<input id="ref-hashtags" v-model="form.hashtags" type="text" placeholder="Ex : écologie, participation, matériaux bio..." />
|
||||
</div>
|
||||
|
||||
<div class="field-group" :class="{ 'field-error': errors.submitted_by_email }">
|
||||
<label for="ref-email">Ton email <span class="label-hint">(optionnel — pour le suivi)</span></label>
|
||||
<input id="ref-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 la référence →' }}</button>
|
||||
</div>
|
||||
<p class="form-note">Ta référence sera examinée avant d'être intégrée au fonds documentaire RAG.</p>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { z } from 'zod'
|
||||
|
||||
const Schema = z.object({
|
||||
titre: z.string().min(3, 'Minimum 3 caractères').max(200, 'Maximum 200 caractères').trim(),
|
||||
auteur: z.string().min(2, 'Minimum 2 caractères').max(200, 'Maximum 200 caractères').trim(),
|
||||
url: z.string().url('URL invalide').optional().or(z.literal('')),
|
||||
description: z.string().min(50, 'Minimum 50 caractères').max(1000, 'Maximum 1000 caractères').trim(),
|
||||
pertinence_rag: z.string().max(500).optional(),
|
||||
hashtags: z.string().max(300).optional(),
|
||||
submitted_by_email: z.string().email('Email invalide').optional().or(z.literal('')),
|
||||
})
|
||||
|
||||
const emit = defineEmits<{ success: [] }>()
|
||||
|
||||
const form = reactive({
|
||||
titre: '', auteur: '', url: '', description: '',
|
||||
pertinence_rag: '', hashtags: '', 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/reference', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
titre: form.titre,
|
||||
auteur: form.auteur,
|
||||
url: form.url || undefined,
|
||||
description: form.description,
|
||||
pertinence_rag: form.pertinence_rag || undefined,
|
||||
hashtags: form.hashtags || 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>
|
||||
158
components/FormReseau.vue
Normal file
158
components/FormReseau.vue
Normal file
@@ -0,0 +1,158 @@
|
||||
<template>
|
||||
<form @submit.prevent="submit" class="proposer-form" novalidate>
|
||||
<div class="field-group" :class="{ 'field-error': errors.nom }">
|
||||
<label for="res-nom">Nom du réseau / collectif / ressource <span class="required">*</span></label>
|
||||
<input id="res-nom" v-model="form.nom" type="text" placeholder="Ex : Collectif des Arpenteurs, Archi'Résilience..." @blur="validateField('nom')" />
|
||||
<span v-if="errors.nom" class="error-msg" role="alert">{{ errors.nom }}</span>
|
||||
</div>
|
||||
|
||||
<div class="field-group" :class="{ 'field-error': errors.type_principal }">
|
||||
<fieldset>
|
||||
<legend>Type <span class="label-hint">(optionnel)</span></legend>
|
||||
<div class="radio-group">
|
||||
<label v-for="t in TYPES" :key="t" class="radio-label" :class="{ active: form.type_principal === t }">
|
||||
<input type="radio" :value="t" v-model="form.type_principal" name="res-type" />{{ LABELS[t] }}
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
<div class="field-group" :class="{ 'field-error': errors.url }">
|
||||
<label for="res-url">Site web <span class="label-hint">(optionnel)</span></label>
|
||||
<input id="res-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="res-desc">Description courte <span class="required">*</span> <span class="label-hint">(50 à 500 caractères)</span></label>
|
||||
<textarea id="res-desc" v-model="form.description_user" rows="4" placeholder="Présente ce réseau/collectif : son objet, son échelle, ce qui le distingue..." @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">
|
||||
<label for="res-ville">Ville principale <span class="label-hint">(optionnel)</span></label>
|
||||
<input id="res-ville" v-model="form.localisation_ville" type="text" placeholder="Ex : Paris, Lyon..." />
|
||||
</div>
|
||||
|
||||
<div class="field-group">
|
||||
<label for="res-projet-lien">Lien vers un projet / réalisation <span class="label-hint">(optionnel)</span></label>
|
||||
<input id="res-projet-lien" v-model="form.projet_lien" type="url" placeholder="https://..." />
|
||||
</div>
|
||||
|
||||
<div class="field-group">
|
||||
<label for="res-projet-adresse">Adresse du projet <span class="label-hint">(optionnel)</span></label>
|
||||
<input id="res-projet-adresse" v-model="form.projet_adresse" type="text" placeholder="Ex : 12 rue des Lilas, 75000 Paris" />
|
||||
</div>
|
||||
|
||||
<div class="field-group" :class="{ 'field-error': errors.submitted_by_email }">
|
||||
<label for="res-email">Ton email <span class="label-hint">(optionnel — pour le suivi)</span></label>
|
||||
<input id="res-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 TYPES = ['agence', 'collectif', 'reseau', 'asso', 'ressource'] as const
|
||||
const LABELS: Record<string, string> = { agence: 'Agence', collectif: 'Collectif', reseau: 'Réseau', asso: 'Association', ressource: 'Ressource' }
|
||||
|
||||
const Schema = z.object({
|
||||
nom: z.string().min(3, 'Minimum 3 caractères').max(150, 'Maximum 150 caractères').trim(),
|
||||
type_principal: z.string().optional(),
|
||||
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(),
|
||||
localisation_ville: z.string().max(100).optional(),
|
||||
projet_lien: z.string().url('URL invalide').optional().or(z.literal('')),
|
||||
projet_adresse: z.string().max(200).optional(),
|
||||
submitted_by_email: z.string().email('Email invalide').optional().or(z.literal('')),
|
||||
})
|
||||
|
||||
const emit = defineEmits<{ success: [] }>()
|
||||
|
||||
const form = reactive({
|
||||
nom: '', type_principal: '', url: '', description_user: '',
|
||||
localisation_ville: '', projet_lien: '', projet_adresse: '', 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: 'reseau',
|
||||
nom: form.nom,
|
||||
type_principal: form.type_principal || undefined,
|
||||
url: form.url || undefined,
|
||||
description_user: form.description_user,
|
||||
localisation_ville: form.localisation_ville || undefined,
|
||||
projet_lien: form.projet_lien || undefined,
|
||||
projet_adresse: form.projet_adresse || 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>
|
||||
240
components/form-styles.css
Normal file
240
components/form-styles.css
Normal file
@@ -0,0 +1,240 @@
|
||||
.proposer-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.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-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);
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.server-error {
|
||||
padding: 0.875rem 1rem;
|
||||
background: #fdf0ee;
|
||||
border: 1px solid #e74c3c;
|
||||
border-radius: 8px;
|
||||
font-size: 0.875rem;
|
||||
color: #c0392b;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.form-note {
|
||||
font-size: 0.75rem;
|
||||
color: var(--nav-text-muted);
|
||||
text-align: center;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.form-actions { flex-direction: column-reverse; }
|
||||
.btn-primary { width: 100%; justify-content: center; }
|
||||
}
|
||||
Reference in New Issue
Block a user